Skip to content

feat: update command — migrator, planner, executor, DiffView (#12)#52

Merged
breferrari merged 13 commits intomainfrom
feat/update-command
Apr 20, 2026
Merged

feat: update command — migrator, planner, executor, DiffView (#12)#52
breferrari merged 13 commits intomainfrom
feat/update-command

Conversation

@breferrari
Copy link
Copy Markdown
Owner

@breferrari breferrari commented Apr 19, 2026

Closes #12. Milestone 4's update command — the moat the whole project is built around.

Summary

  • shardmind update command with the full pipeline: fetch → apply schema migrations → prompt only for newly required values → offer newly optional modules → decide per-file fate for modified files the new shard no longer ships → three-way merge every modified file in parallel → resolve conflicts via DiffView → snapshot-and-write → post-update hook → summary. Any failure between snapshot and state-write walks the snapshot back; the vault is indistinguishable from pre-update.
  • 5 new core modules: migrator.ts, update-planner.ts, update-executor.ts, values-io.ts (shared YAML load for install + update), and the commands/hooks/shared.ts utility (cross-machine summarizeHook + useSigintRollback).
  • 8 new Ink components: DiffView, NewValuesPrompt, NewModulesReview, RemovedFilesReview, UpdateSummary, plus CommandFrame and CommandProgress (shared with install — InstallProgress was renamed).
  • 6 new typed error codes in source/runtime/errors.ts: UPDATE_NO_INSTALL, UPDATE_SOURCE_MISMATCH, UPDATE_CACHE_MISSING, UPDATE_WRITE_FAILED, MIGRATION_INVALID_VERSION, MIGRATION_TRANSFORM_FAILED.
  • 113 new tests; suite 300 → 413 (+38%). Includes 32 adversarial tests, 5 property-based suites × 200 runs (1,000 generative scenarios), 6 component test files, and 9 end-to-end integration scenarios.
  • Deferrals tracked as $EDITOR integration for DiffView conflict resolution #50 ($EDITOR in DiffView) and 24h update-check cache for status + update commands #51 (24h update-check cache shared with the status command).

Highlights

  • Migrator (source/core/migrator.ts) — applyMigrations transforms shard-values.yaml across versions. Filter rule currentVersion < from_version ≤ targetVersion makes re-runs idempotent. Four change types (rename / added / removed / type_changed); rename refuses to clobber an existing target value (warns and keeps both keys) — prevents a class of silent data loss the adversarial audit surfaced.
  • Update planner (source/core/update-planner.ts) — pure. Inputs grouped as { vault, values, newShard, removedFileDecisions } so call sites can't accidentally mix fields from two different shards. Emits UpdatePlan with 10 action variants plus a pendingConflicts queue the state machine drives through DiffView. Per-modified-file merges run in parallel via mapConcurrent(drift.modified, 16, …). theirsHash captured at plan time so keep_mine / skip resolutions don't re-read + re-hash the file.
  • Update executor (source/core/update-executor.ts) — snapshot-based rollback. Backup directory allocation is collision-safe under same-millisecond concurrent updates (ISO timestamp retains ms, then numeric suffix on EEXIST). Snapshot copies touched files + .shardmind/state.json + cached manifest/schema/templates in parallel (SNAPSHOT_CONCURRENCY=16). Write pass runs before delete pass so rename-style moves can't clobber the incoming file. Rollback returns per-file failures (RollbackFailure[]) rather than swallowing them — the command layer surfaces "update failed AND rollback was partial" instead of lying about vault state.
  • DiffView — one conflict at a time with ±3 lines of context, color-coded yours/shard, CRLF-tolerant (splits on /\r?\n/), Select with three active options and one disabled placeholder. Allowlist filter + firedRef guard against double-fire and accidental editor-stub activation.
  • Hook sandbox (source/core/hook.ts) — lookupHook rejects absolute paths and any .. traversal; the resolved hook path must live under the shard's temp directory. Prevents a malicious manifest from probing arbitrary filesystem paths via existence detection.
  • --yes, --verbose, --dry-run flags. Dry-run runs the full pipeline (fetch, migrate, plan, merge) without touching the vault or allocating a backup; the summary reports what would happen.
  • useSigintRollback + summarizeHook shared between install and update. SIGINT always runs the tempdir cleanup — cancelling during the prompt/wizard phase used to leak the extracted tarball.

Commits (chronological)

The branch is 13 commits; notable ones:

  • feat: core/migrator.ts … / core/update-planner.ts / core/update-executor.ts — foundation.
  • refactor: share install+update chrome, cache render pass, parallelize I/O — round 1 cleanup (extracts CommandFrame / CommandProgress / shared.ts).
  • refactor: round-two cleanups — dedup YAML load, group planner input, fix conflictFromDirect — round 2, after review pushback exposed three real issues the first pass dismissed.
  • fix+test: round-three hardening audit (adversarial + property-based) — round 3, four bugs + double-submit guard + 6 component test files + migrator/update adversarial suites.
  • fix+test: round-four /harden audit — round 4, four more bugs (SIGINT handler re-registration, rollback swallowing, hook path traversal, error hint specificity) + hook path-traversal tests + stale-phrasing sweep.

Docs

  • docs/IMPLEMENTATION.md §3 — Update data flow diagram rewritten to the actual useUpdateMachine phase progression.
  • docs/IMPLEMENTATION.md §4.10 — corrected migration filter rule, added the rename-never-clobbers invariant.
  • docs/IMPLEMENTATION.md §4.11 / §4.12 / §4.13 — new module specs for the planner, executor, and values-io.
  • docs/IMPLEMENTATION.md §6.5 — DiffView props updated to match the actual component shape.
  • docs/IMPLEMENTATION.md §7 — error handling table adds the six new codes.
  • docs/ARCHITECTURE.md §10.5 — update flow expanded to cover the new flags and the snapshot/rollback guarantee.
  • CLAUDE.md — module table + source tree include the new files.
  • CHANGELOG.md Unreleased — comprehensive entry covering every user-visible surface.

Test plan

  • npm run typecheck — strict mode clean; zero any / @ts-ignore / @ts-nocheck in the diff.
  • npm test413/413 passing across 38 test files.
  • npm run build — runtime bundle 7.9 KB (under the 30 KB target); dist/commands/update.js emitted.
  • 9 end-to-end scenarios in tests/integration/update.test.ts: silent overwrite, auto-merge, pending conflict w/ keep_mine, up-to-date, new-required-value detection, removed-file decision, keep_as_user untracks from state, dry-run no-mutation, corrupted-cache conflict fallback.
  • 1,000 generative property-based scenarios (fast-check) — migrator idempotence, transitivity, no-key-invention, rename-preserves-data, plan determinism.
  • 6 component test files via ink-testing-library — caught a re-entrant double-submit bug before ship.
  • Manual smoke test against a live GitHub shard — deferred to obsidian-mind conversion (obsidian-mind v6 — convert to shard format #14).

🤖 Generated with Claude Code


Harden Audit

Reference PR: #48

Rounds

  • Round 1 — /simplify: reuse + quality + efficiency cleanups. Extracted CommandFrame / CommandProgress / shared.ts; cached NewFilePlan (removed double-render); parallelized modified-merge + snapshot I/O.
  • Round 2 — round-two cleanups after pushback: grouped PlanUpdateInput into { vault, values, newShard }, extracted values-io.ts, fixed conflictFromDirect dead-branch + wrong-tempdir bug.
  • Round 3 — adversarial + property-based + component tests. Found 4 real bugs: rename clobbers values, same-second backup-dir collision, DiffView CRLF corruption, keep_as_user left files tracked. Plus a re-entrant double-submit in NewValuesPrompt / RemovedFilesReview that the new component tests caught.
  • Round 4 — /harden fresh-eyes pass. Four more: SIGINT handler re-registered every render, rollback failures silently swallowed, hook path traversal probe, vague UPDATE_CACHE_MISSING hint. Also swept code comments for stale temporal phrasing.

Real bugs found

  • core/migrator.tsrename silently overwrote target; now refuses + warns.
  • core/update-executor.ts :: createBackupDir — same-second timestamp collision; now ms + numeric-suffix probe.
  • components/DiffView.tsx\r leaked through \n-only split and corrupted the terminal; splits on /\r?\n/.
  • core/update-executor.ts :: applyWriteActionkeep_as_user kept the entry in state.files (the user chose "untrack" but the engine still considered it managed); now deletes.
  • commands/hooks/shared.ts :: useSigintRollback — deps were inline arrows, teardown+re-register on every render; refs pin the callbacks.
  • core/update-executor.ts :: runUpdate — rollback errors swallowed via .catch(() => {}); now rollbackUpdate returns a failure list which is attached to the thrown error.
  • core/hook.ts :: lookupHook — path traversal / absolute paths allowed a shard to probe arbitrary filesystem paths; now sandboxed to tempDir.
  • core/update-planner.ts :: conflictFromDirect — passed '' as tempDir, producing absolute templateKey; fixed to thread newTempDir through.

Tests added

  • Unit: 18 migrator adversarial + 4 property-based × 200 runs (800 scenarios), 11 update planner/executor adversarial + 1 property, 6 hook path-traversal. Regression coverage for every fixed bug.
  • Component: 6 new files using ink-testing-library — DiffView, CommandFrame, NewValuesPrompt, NewModulesReview, RemovedFilesReview, UpdateSummary.
  • Integration: dry-run no-mutation assertion, conflictFromDirect fallback via wiped cache, keep_as_user untracks from state.
  • Tests-before → tests-after: 300 → 413 (+113; suite grew by 38%).

Deferrals

breferrari and others added 7 commits April 20, 2026 01:14
Adds the fourth and final engine pipeline module: applyMigrations walks
rename/added/removed/type_changed changes whose from_version falls in
(currentVersion, targetVersion], preserving values on best-effort
anomalies (missing keys, throwing transforms) so a single bad migration
can't block the upgrade. Zod validation against the new schema still
catches real breakage after the pass.

16 unit tests cover each change type, version-range selection, warn-only
semantics, and a multi-version chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
planUpdate maps the DriftReport + schema diff + new-shard render into a
list of per-file UpdateActions: overwrite managed, three-way merge
modified, add new, delete/keep-as-user removed, restore missing, skip
volatile. Pending conflicts are surfaced as a separate list so the
state machine can drive DiffView between planner and executor.

Also exports three small helpers the state machine needs before
planning: computeSchemaAdditions (new required keys + offered modules),
mergeModuleSelections (carry-over + opt-in), and
removedFilesNeedingDecision (only modified removals need a prompt).

17 unit tests cover each action branch against a synthetic temp vault.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
runUpdate applies an UpdatePlan against a real vault: snapshot every
path it will touch (files + .shardmind cache) into
.shardmind/backups/update-<ts>/, write/merge/delete per action, re-cache
manifest + schema + templates, write new state.json, run the
post-update hook. Any failure between snapshot and state write triggers
rollbackUpdate, which restores the snapshot tree and removes files
added mid-flight. Dry-run path skips all writes and the snapshot.

Adds runPostUpdateHook alongside runPostInstallHook — same deferred
execution contract (#30) covers both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six new components for the update flow:

- DiffView — one-conflict-at-a-time viewer with ±3 lines of context,
  color-coded yours/shard regions, and per-file action select
  (accept new / keep mine / skip; editor integration stubbed for v0.2).
- NewValuesPrompt — asks only for value keys that migrations couldn't
  fill (thin wrapper over ValueInput).
- NewModulesReview — multiselect for modules present in the new shard
  but absent from the prior install.
- RemovedFilesReview — per-file keep-or-delete decision for modified
  files dropped from the new shard; managed removals auto-delete.
- UpdateProgress — progress bar + verbose history (mirrors
  InstallProgress).
- UpdateSummary — from→to line, action counts, conflict resolution
  breakdown, migration warnings, hook output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sibling of commands/install.tsx. useUpdateMachine owns every
side-effecting transition behind a hook interface so update.tsx stays
thin presentation:

  booting → loading → (no-install | up-to-date | migrating →
    prompt-new-values → prompt-new-modules → prompt-removed-files →
    resolving-conflicts → writing → summary)

SIGINT handler reads the executor's backup dir to roll back mid-write.
--yes auto-keeps conflicts and opts into every new optional module so
CI upgrades are unattended-friendly.

Adds commands/update to tsup.config entry points and refreshes the
install machine's now-inaccurate "update not yet available" hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six tests against examples/minimal-shard that drive install → modify →
detectDrift → applyMigrations → planUpdate → runUpdate against real
filesystem state (no network):

- managed file silently overwritten on version bump with template change
- modified file with non-overlapping edits three-way merges cleanly
- modified file with overlapping edits surfaces a pending conflict,
  keep_mine preserves the user's version and marks it 'modified' in
  state.json
- up-to-date precondition (same version + same tarball sha)
- new required-value detection via computeSchemaAdditions
- removedFilesNeedingDecision flags modified files dropped from the new
  shard

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#12 shipped. Status (#13) is a separate PR. Full CLI E2E comes with M6
(#15 — publish) where we already planned it.

Two v0.2 follow-ups surfaced while scoping #12:
- #50 — $EDITOR integration for DiffView (v0.1 stubbed the option).
- #51 — 24h update-check cache shared between status (#13) and update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 19, 2026 23:17
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the v0.1 shardmind update pipeline to the CLI/TUI, including value migrations, a pure update planner, a rollback-capable executor, and Ink UI for resolving merge conflicts—backed by new unit + integration tests.

Changes:

  • Introduces core update engine modules: migrator, update-planner, and update-executor (with backups + rollback).
  • Adds shardmind update command with a useUpdateMachine orchestrator and Ink components (DiffView, prompts, progress, summary).
  • Expands tests with unit coverage for migrations/planning and an end-to-end update pipeline integration suite.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tsup.config.ts Emits dist/commands/update.js for Pastel routing.
source/runtime/errors.ts Adds update/migration-related ShardMindError codes.
source/core/migrator.ts Applies schema migrations between shard versions.
source/core/update-planner.ts Renders new shard outputs and produces an UpdatePlan + pending conflicts.
source/core/update-executor.ts Executes the plan with snapshot/rollback and writes updated state/values/cache.
source/core/hook.ts Adds post-update hook detection (runPostUpdateHook).
source/components/DiffView.tsx Renders conflict regions and collects per-file conflict decisions.
source/components/NewValuesPrompt.tsx Prompts for newly-required values missing after migration.
source/components/NewModulesReview.tsx Prompts for opting into new optional modules.
source/components/RemovedFilesReview.tsx Prompts for modified files removed by the new shard.
source/components/UpdateProgress.tsx Shows progress bar + optional history while writing.
source/components/UpdateSummary.tsx Summarizes update results and migration warnings.
source/commands/update.tsx New shardmind update command UI wiring.
source/commands/hooks/use-update-machine.ts Orchestrates the full update flow (resolve/download/migrate/plan/execute).
source/commands/hooks/use-install-machine.ts Updates messaging to point users at shardmind update.
ROADMAP.md Marks update command + integration test as completed; records deferrals.
tests/unit/migrator.test.ts Unit tests for migration selection + change application.
tests/unit/update-planner.test.ts Unit/integration-style tests for planner behavior.
tests/integration/update.test.ts End-to-end update pipeline scenarios against examples/minimal-shard.

Comment thread source/core/update-planner.ts Outdated
Comment on lines +296 to +302
// Missing: file recorded in state but gone from disk. If the new shard
// still ships it, restore. If it also disappeared upstream, leave alone.
for (const entry of drift.missing) {
const target = newByPath.get(entry.path);
if (!target) {
actions.push({ kind: 'delete', path: entry.path });
counts.deleted++;
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "If it also disappeared upstream, leave alone", but the code emits a delete action (which will remove the entry from state and attempt an rm). If the intended behavior is to stop tracking missing files that the shard no longer produces, consider updating the comment to match; otherwise adjust the logic.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale — the misleading comment is already gone. The current drift.missing branch (update-planner.ts:328–344) has no such text; it emits delete when the new shard no longer produces the file, restore_missing otherwise. No action needed on current HEAD.

Comment on lines +161 to +170
// SIGINT: if we're mid-write, use the backup dir to roll back.
useEffect(() => {
const handler = () => {
if (writingRef.current && backupDirRef.current && !dryRun) {
rollbackUpdate(vaultRoot, backupDirRef.current, addedPathsRef.current)
.catch(() => {})
.finally(() => process.exit(130));
} else {
process.exit(130);
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SIGINT rollback path is effectively unreachable right now: backupDirRef.current is only assigned after runUpdate resolves, but Ctrl+C happens while runUpdate is in flight. Also addedPathsRef.current is never populated from the executor, so even if rollback ran it wouldn't remove newly-added files. Consider having runUpdate (or the executor) surface backupDir immediately (callback/progress event) and stream added paths so the SIGINT handler can reliably roll back mid-run.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SIGINT rollback path is now reachable. Fixed in commit d600dab: the executor exposes onBackupReady (fires after snapshot is staged, before any write) and onFileTouched (fires per write with an introduced boolean). The state machine populates backupDirRef + addedPathsRef eagerly from those callbacks, so a mid-write Ctrl-C now finds live rollback state. Integration test fires onBackupReady before any writes, so SIGINT can roll back mid-run locks the ordering.

Comment on lines +527 to +531
/**
* Overwrite `shard-values.yaml` (the install executor uses `wx` so it
* refuses to overwrite; here we *want* to replace it atomically).
*/
async function writeValuesFile(
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment claims the update overwrites shard-values.yaml "atomically", but the implementation uses a direct writeFile to the final path. If atomicity matters (e.g., crash safety), consider writing to a temp file in the same directory and rename-ing, or adjust the comment to avoid promising atomic semantics.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d600dab — the docstring on writeValuesFile now says the atomicity comes from the snapshot-based rollback, not the write itself. Not switching to temp+rename because rollback already covers the crash-during-write case; added the note so the claim doesn't overpromise.

Comment thread source/core/update-executor.ts Outdated
Comment on lines +128 to +161
const writable = plan.actions.filter((a) => actionWrites(a, conflictResolutions));
onProgress?.({ kind: 'start', total: writable.length });

const nextFiles: Record<string, FileState> = { ...currentState.files };
const summary: UpdateSummary = {
fromVersion: currentState.version,
toVersion: newManifest.version,
counts: plan.counts,
conflictsResolved: 0,
conflictsKeptMine: 0,
conflictsSkipped: 0,
conflictsAcceptedNew: 0,
autoMergeStats: { linesUnchanged: 0, linesAutoMerged: 0 },
wroteFiles: [],
deletedFiles: [],
};

// Two-pass: writes first, deletes second. Writes use mkdir -p so they
// can create parents. Deletes after means a rename-style move (delete
// + add at a different path) won't clobber a new file.
let index = 0;
for (const action of plan.actions) {
if (isDeleteAction(action, conflictResolutions)) continue;
await applyWriteAction(action, {
vaultRoot,
conflictResolutions,
nextFiles,
summary,
addedPaths,
dryRun,
onProgress,
index: ++index,
total: writable.length,
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writable is computed via actionWrites, but the executor still iterates all non-delete actions and emits progress events for them. For conflicts resolved as keep_mine/skip, actionWrites returns false, so total can be smaller than the number of progress updates and index can exceed total (progress bar can go past 100%). Consider defining total as the number of actions you will actually process in these loops, or skip processing/emitting progress for actions you exclude from writable.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real bug, fixed in d600dab. Added actionEmitsProgress helper (covers conflict regardless of resolution) and use it for progressTotal instead of writable.length. actionWrites was retired since its only callsite was the broken total calculation.

Comment on lines +256 to +260
if (!ctx.dryRun) {
const wasExisting = await pathExists(path.join(ctx.vaultRoot, action.path));
await writeFile(ctx.vaultRoot, action.path, action.content);
if (!wasExisting) ctx.addedPaths.push(action.path);
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add actions can overwrite an existing on-disk file: wasExisting is only used to decide whether to record addedPaths, but writeFile always overwrites. This is especially risky because snapshotForRollback does not snapshot add paths, so an update could destroy an untracked user file with no rollback restore. Consider treating add as a collision when the path already exists (prompt/skip/error), or snapshot and preserve the pre-existing content before overwriting.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d600dab. snapshotForRollback now includes add paths too — copyOptional ENOENTs on the normal (non-existent) case, but captures the pre-existing user file when there is one, so rollback restores it. addedPaths is now populated only when the path was actually absent pre-write (checked via pathExists), so rollback erases only the paths this run newly introduced.

Comment thread source/core/update-planner.ts Outdated
Comment on lines +214 to +222
for (const entry of resolution.copy) {
const buffer = await fsp.readFile(entry.sourcePath);
const content = buffer.toString('utf-8');
outputs.push({
outputPath: entry.outputPath,
entry,
content,
hash: sha256(buffer),
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderNewShard reads copied files as UTF-8 strings (buffer.toString('utf-8')). The install path writes resolution.copy entries as raw buffers, so updates may corrupt non-text/binary assets (images, PDFs, etc.) and also make hashes/content comparisons inconsistent. Consider carrying copy-file content as a Buffer through the plan (or re-reading as Buffer in the executor) and writing with a buffer-safe writer, mirroring install-executor behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d600dab. RenderedFileEntry now carries an optional copyFromSourcePath for copy-origin entries (binary assets, scripts, anything outside Nunjucks). Planner threads it onto add / overwrite / restore_missing actions. Executor's new writeAction prefers fsp.copyFile when that pointer is present, otherwise writes content as UTF-8. Bytes survive round-trip. Regression test asserts sha256 of a binary fixture round-trips identical.

Comment thread source/core/update-planner.ts Outdated
Comment on lines +414 to +425
// Added: files in the new shard that don't exist in state.
const trackedPaths = new Set(Object.keys(currentState.files));
for (const output of newPlan.outputs) {
if (trackedPaths.has(output.outputPath)) continue;
actions.push({
kind: 'add',
path: output.outputPath,
content: output.content,
renderedHash: output.hash,
templateKey: toTemplateKey(newTempDir, output.entry.sourcePath),
...(output.entry.iterator ? { iteratorKey: output.entry.iterator } : {}),
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "added" pass only checks currentState.files to decide whether a new output is an add. If a user already has an untracked file at output.outputPath (often reported in drift.orphaned, but also possible outside tracked dirs), this will plan an add and the executor will overwrite it silently. Consider treating existing-on-disk paths as collisions (prompt/skip/conflict) and/or incorporating drift.orphaned (and/or an fs.stat check) before emitting add actions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed as part of the add-safety fix in d600dab. Rather than promoting existing-on-disk to a user-facing collision prompt (which would be a wizard-level behavior change — install has the same question via ExistingInstallGate), the executor now snapshots the pre-existing file before the add write, so a failed update's rollback restores it. For a successful update where the user had an orphan at the same path, the update quietly adopts the shard's version — matches install-time behavior. Follow-up to surface the collision interactively would be a --confirm-collisions flag; low priority given the snapshot-restore floor.

Comment thread source/core/update-planner.ts Outdated
},
newContent: target.content,
newContentHash: target.hash,
templateKey: toTemplateKey('', target.entry.sourcePath),
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conflictFromDirect sets templateKey via toTemplateKey('', target.entry.sourcePath), which will store an absolute filesystem path in state (because tempDir is empty). That will later break cache lookups that expect a templates/... relative key. Consider using the real newTempDir to compute a relative key here, or setting templateKey to null if it cannot be determined safely.

Suggested change
templateKey: toTemplateKey('', target.entry.sourcePath),
templateKey: null,

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale — already fixed in commit 21a900b (round 2 cleanup). conflictFromDirect now receives the real newTempDir and produces a cache-relative templateKey matching the other branches. Current code at update-planner.ts:495-540 reflects the fix.

breferrari and others added 5 commits April 20, 2026 01:32
… I/O

Cleanup pass after #12 lands. Same behavior, less duplication, faster
planning and rollback prep.

Deduplication:
- Extract `CommandFrame` — dry-run banner + keyboard legend — used by
  both install and update commands.
- Rename `InstallProgress` → `CommandProgress` and delete the identical
  `UpdateProgress`. One component now.
- Extract `summarizeHook` + `useSigintRollback` to `hooks/shared.ts`.
  Both machines drop their copies.
- Move `mapConcurrent` from drift.ts to fs-utils.ts so planner +
  executor can share it.

Perf:
- Render the new shard once per update: planner accepts a precomputed
  `NewFilePlan`, the state machine threads the one rendered at the
  removed-files check straight through. Previously: two full render
  passes per update.
- `renderNewShard` now runs `render` + `copy` passes in parallel with
  bounded concurrency.
- `planUpdate` runs the per-modified-file merge loop in parallel
  instead of awaiting each `readFile` + `computeMergeAction` in turn.
- `snapshotForRollback` copies snapshot files with bounded concurrency
  instead of a serial `for` loop.

Bug fix:
- `keep_as_user` now deletes the entry from `state.files`. Previously
  the file stayed on disk (correct) but was still tracked as the old
  managed path (wrong — the user chose "untrack"). Covered by a new
  integration test.

Smaller cleanups:
- Conflict actions carry `theirsHash` from the planner so the executor
  skips the re-read + re-hash on keep_mine/skip.
- Write actions derive "newly introduced" from `action.kind` instead
  of a pre-write `pathExists` stat.
- Remove unused `oldRenderContext` field (planner rendered from
  `newRenderContext` only).
- Simplify `NewValuesPrompt` + `RemovedFilesReview`: call the
  completion callback inline on the threshold-crossing submit instead
  of a `done` boolean + `useEffect`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fix conflictFromDirect

Second pass after pushback on the first /simplify — several "not worth"
dismissals turned out to be worth doing.

1. `conflictFromDirect` had a dead branch and a real bug. `target.entry
   .sourcePath` is typed `string` — never null — so half the `||` was
   unreachable. More importantly, the function called
   `toTemplateKey('', target.entry.sourcePath)` which silently produced
   an absolute path instead of a cache-relative key. If the fallback
   ever fired, the next update's `loadOldTemplate` lookup would fail.
   Now threaded through `newTempDir`, matching the other branches.

2. Extract `source/core/values-io.ts :: loadValuesYaml` — one helper
   for the two shard-values.yaml readers (install's --values prefill
   and update's canonical load). Same read → parse → validate-mapping
   flow; the only real difference was schema-based key filtering, now
   a parameter. Kills ~30 lines of near-duplicate error-handling code.

3. Restructure `PlanUpdateInput` from 11 flat fields into grouped
   sub-objects (`vault`, `values`, `newShard`, `removedFileDecisions`).
   The newShard cluster (schema + selections + tempDir + renderContext
   + filePlan) always travels together — grouping prevents callers
   from accidentally mixing fields from two different shards. 14 test
   sites migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aimed at the #48 bar. Four real bugs were hiding under the first pass,
all caught by a deliberate adversarial audit and the component-test
precedent install already set.

## Bugs fixed

- **Data loss in migrator** (`core/migrator.ts`) — `rename: a → b` silently
  overwrote an existing value at `b`. Now refuses the overwrite, warns,
  and keeps both keys so the user can untangle manually. Regression test
  in `migrator.test.ts`.
- **Concurrent backup-dir collision** (`core/update-executor.ts`) — the
  ISO timestamp stripped fractional seconds, so two same-second updates
  allocated the same `.shardmind/backups/update-<ts>/` directory and
  clobbered each other's rollback snapshots. Timestamp now retains ms
  and a numeric suffix is probed on EEXIST. Regression in
  `update-adversarial.test.ts`.
- **DiffView CRLF corruption** (`components/DiffView.tsx`) — the view
  split on `\n` only, leaving `\r` on each line. Ink treated those as
  carriage returns and corrupted the terminal when rendering Windows-
  saved user files. Now splits on `/\r?\n/` everywhere to match
  `differ.ts`.
- **keep_as_user left files tracked** (`core/update-executor.ts`) — the
  "keep my edits (untrack)" decision kept the file in `state.files`;
  next update still considered it managed. Now drops the entry on apply.
  Regression in `update.test.ts`.

## Also

- SIGINT now always runs the shard tempdir cleanup — cancelling during
  the prompt/wizard phase used to leak the extracted tarball.
- `DiffView`'s choice handler uses a `Set<DiffAction>` allowlist instead
  of a bare cast, so the disabled "Open in editor" option can never
  bypass the v0.2 stub.
- `useSigintRollback` API cleaned up: drops the redundant `enabled`
  param (caller folds the condition into `isActive`) and adds a
  `cleanup` callback that runs on every Ctrl-C.
- `conflictFromDirect` now produces a cache-relative `templateKey`
  (previously passed `''` for tempDir and emitted an absolute path
  that would break future updates if the fallback ever fired).
- `NewValuesPrompt` + `RemovedFilesReview` use a `submittedRef` +
  per-file `key` to prevent the re-entrant double-submit that the
  new component tests caught.

## Tests added (62 new)

- `tests/unit/migrator-adversarial.test.ts` — 18 tests incl. 4
  `fast-check` properties × 200 runs = 800 generative scenarios.
  Covers prototype-key pollution, non-Error throws, unserializable
  transforms, BOM + null bytes, cyclic values, pre-release + build-
  metadata semver, chained renames, idempotence, transitivity.
- `tests/unit/update-adversarial.test.ts` — 11 tests covering missing
  cached templates (→ `conflictFromDirect` fallback), iterator-shrink
  cleanup, CRLF user files + LF templates, Unicode/emoji filenames,
  inconsistent drift + state, concurrent `createBackupDir`, rollback
  idempotency, render determinism (property).
- `tests/component/` — 6 new files for `DiffView`, `CommandFrame`,
  `NewValuesPrompt`, `NewModulesReview`, `RemovedFilesReview`,
  `UpdateSummary`, matching the 9-file precedent set by install.
  DiffView alone covers header rendering, context lines, singular/
  plural region labels, each DiffAction, disabled-option filtering,
  and CRLF robustness.
- `tests/integration/update.test.ts` — 2 new cases: `--dryRun` does
  not mutate the vault; corrupted template cache surfaces the file
  as a full-file conflict via `conflictFromDirect`.

341 → 405 tests passing.

## Docs

- `docs/IMPLEMENTATION.md` §3 — update data flow diagram rewritten to
  match the actual `useUpdateMachine` phase progression (three
  discrete prompt phases, explicit rollback branch).
- `docs/IMPLEMENTATION.md` §4.10 — corrected migration filter rule
  (`currentVersion < from_version ≤ targetVersion`), documented the
  rename-never-clobbers invariant and `MIGRATION_INVALID_VERSION`.
- `docs/IMPLEMENTATION.md` §4.11 / §4.12 / §4.13 — new module specs
  for `update-planner.ts`, `update-executor.ts`, `values-io.ts` in
  the same §4.x style as the existing engine modules.
- `docs/IMPLEMENTATION.md` §6.5 — DiffView prop spec updated to
  reflect `index` / `total` / `result` / `DiffAction` (was out of
  date) and the CRLF + Set-allowlist invariants.
- `docs/IMPLEMENTATION.md` §7 — error handling table adds the six
  new update + migration error codes.
- `docs/ARCHITECTURE.md` §10.5 — update flow covers `--yes` /
  `--verbose` / `--dry-run`, removed-files decision prompt, snapshot
  guarantee; links to IMPLEMENTATION §4.11 / §4.12.
- `CLAUDE.md` — module table + source tree diagram include the new
  core modules, components, and `commands/hooks/` directory.
- `CHANGELOG.md` — comprehensive Unreleased entry covering every
  user-visible surface, following the #48 tone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit follow-ups while doing the fresh-eyes pass:

- `NewFilePlan.entriesByPath` was populated but never consumed. Drop
  it and introduce a named `RenderedFileEntry` type so internals that
  previously indexed `NewFilePlan['outputs'][number]` read cleanly.
- `PendingConflict.index` was always `array.indexOf(self)` — dead
  weight. Removed from the type and the construction site.
- `DiffView` now guards `onChoice` with a `firedRef` (same pattern
  used by `NewValuesPrompt` + `RemovedFilesReview`), and keys the
  underlying `Select` on `filePath` so each conflict gets a fresh
  focus context. Prevents Ink re-fires from resolving a subsequent
  file's conflict with the same keypress. Regression test added.

406 tests passing (was 405).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fresh-eyes pass found four real issues the prior rounds missed.

- **useSigintRollback** re-registered the SIGINT handler on every
  render because its deps were inline arrows from the caller. Pins
  the callbacks in refs and registers once on mount.
- **rollbackUpdate** now returns a list of per-file failures instead
  of void; runUpdate attaches them to the thrown error's message so
  the command layer can surface "update failed AND rollback was
  partial" — silent swallowing was telling users the vault was back
  to pre-update state when it wasn't.
- **Hook path traversal**: a shard manifest declaring
  `hooks.post-update: "../../etc/shadow"` could probe arbitrary
  filesystem paths via the existence detection. `lookupHook` now
  rejects traversal and absolute paths; resolved hook path must live
  under the shard's temp directory. Six unit tests lock the guard.
- **UPDATE_CACHE_MISSING** hint now names the exact
  `shardmind install <source>` command to recover, and includes the
  original parse error.

Also: swept the diff for stopgap-ish phrasing ("v0.1 doesn't X yet",
"v0.2 stub") and rewrote to describe the permanent invariants
instead. Temporal framing in code + tests rots as the roadmap
advances; reserved it to docs sections that are explicitly about
phase-gated decisions.

413 passing (was 406, +7 for hook tests + rollback-failure test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 2 comments.

Comment on lines +167 to +173
useSigintRollback({
isActive: () => !dryRun && writingRef.current && backupDirRef.current !== null,
rollback: async () => {
if (backupDirRef.current) {
await rollbackUpdate(vaultRoot, backupDirRef.current, addedPathsRef.current);
}
},
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SIGINT rollback can’t work during the write phase: backupDirRef.current is only set after runUpdate() resolves, so isActive() is false while writes are in progress. Also addedPathsRef is reset but never populated, so even if rollback ran it wouldn’t remove newly-added files. Suggestion: expose backupDir (and addedPaths) from the executor earlier (e.g., callback/event when backup dir is created and when a path is newly added) or move backup allocation/snapshotting into the machine so refs are set before the first write.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same SIGINT issue as 3107674252 — addressed by exposing onBackupReady + onFileTouched from the executor so the state machine can populate its refs mid-run instead of waiting for the return value. Commit d600dab.

return {
outputPath: entry.outputPath,
entry,
content: buffer.toString('utf-8'),
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderNewShard() reads copy-entry files as a Buffer but then coerces them to UTF-8 (buffer.toString('utf-8')) and stores them in content: string. The executor subsequently writes content via fsp.writeFile(..., 'utf-8'). This breaks byte-for-byte updates for any non-UTF8 or binary files (and is inconsistent with install, which writes resolution.copy entries as raw buffers). Suggestion: carry copied file contents as Buffer (or a distinct action type for copied assets) and have the executor write buffers without re-encoding; also ensure modified copied files don’t go through the template-based three-way merge path.

Suggested change
content: buffer.toString('utf-8'),
// Preserve copied assets as raw bytes so updates remain
// byte-for-byte identical for binary and non-UTF-8 files.
content: buffer,

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as 3107674269 — fixed in d600dab. Went with the copyFromSourcePath approach rather than storing Buffers in actions: keeps the merge-engine text pipeline clean (it only cares about rendered strings) while giving the executor a byte-safe path for copy-origin files. Merge/diff never touches copy-origin content anyway, so the string content there is informational-only.

Six real findings from Copilot. Two were stale (already fixed in
earlier rounds). One was a doc overpromise. The other four were
correctness bugs worth fixing before merge.

- **SIGINT rollback unreachable** (`use-update-machine.ts` +
  `update-executor.ts`). `backupDirRef.current` was only assigned
  after `runUpdate` resolved, but Ctrl-C fires during the run — so
  the handler always saw `null` and skipped rollback. Executor now
  exposes `onBackupReady` (fires after snapshot, before any write)
  and `onFileTouched` (fires per write with `introduced: boolean`).
  The machine populates its refs eagerly via these callbacks.
  Integration test locks the ordering (backup callback fires before
  any file write).
- **Progress bar could overshoot 100%** (`update-executor.ts`).
  `total` was `writable.length` which excludes keep_mine/skip
  conflicts, but the loop emitted progress events for them anyway —
  so `index` could exceed `total`. New `actionEmitsProgress` helper
  computes the progress total correctly; `actionWrites` retired.
- **`add` could silently overwrite an untracked user file with no
  rollback restore** (`update-executor.ts`). Snapshot pass now
  includes `add` paths too — if a user has a pre-existing file at
  the same path (an orphan, or an untracked collision), it's in the
  backup and rollback restores it. `addedPaths` only tracks paths
  this run actually introduced, so rollback erases the right set.
- **Copy-origin binary assets corrupted by UTF-8 round-trip**
  (`update-planner.ts` + `update-executor.ts`). `renderNewShard`
  now carries a `copyFromSourcePath` pointer on copy-origin
  entries; planner threads it onto `add` / `overwrite` /
  `restore_missing` actions; executor's new `writeAction` prefers
  `fsp.copyFile` over `fsp.writeFile(..., 'utf-8')` when that
  pointer is present. Bytes survive the round trip. Test locks
  binary-hash equality.
- **Stale "atomically" comment** on `writeValuesFile` — clarified
  that atomicity comes from the snapshot-based rollback, not from
  the write itself.

**Stale findings (already fixed earlier, noted in response):**
- `drift.missing` "leave alone" comment mismatch — comment was
  already gone by round 2.
- `conflictFromDirect` absolute `templateKey` — fixed in commit
  21a900b (round 2).

415 passing (was 413, +2 tests: binary-bytes preservation, SIGINT-
reachability ordering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@breferrari breferrari merged commit 35d11ff into main Apr 20, 2026
6 checks passed
@breferrari breferrari deleted the feat/update-command branch April 20, 2026 01:37
breferrari added a commit that referenced this pull request Apr 20, 2026
…ts (#54)

Three real defects shipped inside the E2E scope because the subprocess
surface exposed them and because they're too rough to defer:

1. UPDATE_NO_INSTALL was declared in source/runtime/errors.ts but never
   thrown — use-update-machine.ts handled the null-state case with a
   friendly no-install phase variant (exit 0). CHANGELOG claimed the code
   shipped in PR #52 but it was dead. Throwing the typed error surfaces
   a code + hint to stdout and exits 1 so scripting / CI can branch.

2. UPDATE_SOURCE_MISMATCH was also declared but never thrown. When
   state.source is malformed, resolveRef throws REGISTRY_INVALID_REF with
   a hint that tells the user to fix their CLI input — but during update
   the ref came from .shardmind/state.json, so that hint is actively
   misdirecting. Wrap with the typed code + a remediation hint that
   points at state.json and embeds the malformed value.

3. SHARD_NOT_FOUND / VERSION_NOT_FOUND via resolveRef carry install-path
   hints ("Check spelling", "Pick an available version or omit @Version")
   that equally misdirect during update. Rewrite the hints for the
   update audience while keeping the codes — the user gets the same
   code they'd see during install but a remediation that makes sense
   for state-sourced refs.

The resolve+rewrite logic is extracted as `resolveRefForUpdate` and the
null-state throw as `throwNoInstall`, with a `lookupUpdateTarget`
orchestrator that composes both — all exported for unit testing. The
useEffect in the hook now just calls `lookupUpdateTarget(vaultRoot)` and
the terminal-state rendering in update.tsx drops the no-install phase
block entirely. Error rendering path in update.tsx gains exit 1 via the
refactor in a sibling commit.

7 new unit tests in tests/unit/update-machine.test.ts pin every throw
path and every hint rewrite (including the passthrough cases for
REGISTRY_NETWORK where the hint is already context-agnostic) and assert
`lookupUpdateTarget` fails with UPDATE_NO_INSTALL on a missing state,
UPDATE_SOURCE_MISMATCH on a malformed state.source, and succeeds with a
ResolvedShard when resolution works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

commands/update.tsx — upgrade flow with drift detection + DiffView

2 participants