@sabaiway/agent-workflow-kit 3.11.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 3.12.0 — `--resume` tolerates the session's work: the verify proves per placed path (AD-073)
8
+
9
+ `provision --resume` no longer refuses a satellite you have worked in. The closing slice of the
10
+ resume-verify design: the post-provision verify stopped asking "is the whole tree clean?" and now
11
+ asks "is every path THIS run placed or kept in a git lane provision can prove?" — so your
12
+ uncommitted edits, untracked scratch and hook-created files are out of scope BY CONSTRUCTION.
13
+
14
+ - **What `--resume` now tolerates:** uncommitted tracked edits (including a dirty `package.json`,
15
+ whose live state steers the refreshed install posture in both directions), untracked scratch at
16
+ any depth, renamed tracked files, hook-created content, and every ignored file. A worktree wedged
17
+ by a `post-checkout` hook that dirtied its fresh checkout now completes on `--resume` — previously
18
+ both lanes refused at the same verify and the only way out was deleting the hook's files by hand.
19
+ - **What still refuses, fail-closed:** a path provision itself placed or kept whose lane is
20
+ UNTRACKED, and any lane probe that errors. The STOP names the exact leaf — never a directory, never
21
+ a session path — and carries the convergent fix first: restore the ignore rule (the only
22
+ convergent fix for copy-set leaves, the `node_modules` link and `.vscode/settings.json`, since the
23
+ next resume simply re-places a removed node). A droppable `--include` gets ONE instruction for its
24
+ whole destination ROOT — dropping the flag orphans every copy under it — namely: move the root OUT
25
+ of the worktree and drop `--include <root>` together. No removal command is ever derived (the tool
26
+ cannot see what else you put inside that directory), and leaving it in place is not offered either
27
+ (an orphan is what blocks landing). A node this attempt did not create is never advised away, and
28
+ an unprovable probe carries no recovery command at all.
29
+ - **The proof set is a closed, frozen registry** (`PLACEMENT_REGISTRY`): the handoff stub, the seeded
30
+ plan, copy-set leaves, include leaves, the `node_modules` link, `.vscode/settings.json`,
31
+ pin-rebase targets, and the record refresh. It is leaf-only and kind-gated — a real `node_modules`
32
+ directory where the tool places a symlink is YOUR content, never probed and never touched — and it
33
+ freezes at the verify, so the record refresh can only write at the path the stub already
34
+ journaled. The kind gate applies to KEPT nodes only: anything the run itself created stays proven.
35
+ - **The lane probes are literal.** Live-probed on git 2.43: `check-ignore` refuses pathspec magic and
36
+ otherwise answers for a name that glob-matches a TRACKED sibling, so a file literally named
37
+ `feature-[a].md` would read "not ignored" merely because `feature-a.md` is tracked. Tracked-ness is
38
+ decided first by an explicit literal pathspec whose output must match the path BYTE-EXACTLY (a
39
+ pathspec naming a directory lists its descendants, which prove nothing about the path itself), and
40
+ the ignore probe runs `--no-index`. A `.vscode/settings.json` an earlier run placed is proven even
41
+ when the current run's door skips writing it, so a lost ignore rule can no longer hide behind a
42
+ successful resume.
43
+ - **The FIRST provision stays deliberately strict** — any dirt still refuses — and its untracked
44
+ visibility is now explicit, so a repo's `status.showUntrackedFiles=no` can no longer turn that
45
+ check into a silent no-op. Default behavior is unchanged.
46
+ - **The contract ships as a live constant** (`RESUME_VERIFY_RULE`) printed on every resume-verify
47
+ STOP and pinned into the mode doc by the doc-parity gate. The record's fields are documented for
48
+ what they are: `slug`, `branch` and the seeded plan name authorize a resume; `include`,
49
+ `node_modules` and `vscode-settings` are recorded facts that never do.
50
+
7
51
  ## 3.11.0 — the record attests only a verified provision; tracked plans-chain paths refuse (AD-072)
8
52
 
9
53
  Two provision honesty fixes from the converged resume-verify design (its slice R1; the
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '3.11.0'
6
+ version: '3.12.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "3.11.0",
6
+ "version": "3.12.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -38,6 +38,36 @@ own verbatim error through the existing Git-error surface.
38
38
  record attests only a verified provision, and a failed run leaves the prior record bytes: on a
39
39
  first provision that failed after the stub write, the stub; a refusal BEFORE any provision
40
40
  write (the tracked-plans-chain and probe-error STOPs) leaves no handoff at all.
41
+ The post-provision verify is lane-specific:
42
+ the resume verify proves only what THIS run placed or kept: every journaled leaf must be tracked or ignored in the worktree, an untracked owned leaf or any probe error stops the run naming the exact leaf, and every other path — the session's own work — is never probed and never a stop cause; a first provision keeps the blanket clean-tree verify.
43
+ The proof list is THIS run's live placement journal — the closed-world placement registry (the
44
+ handoff stub, the seeded plan, copy-set leaves, include leaves, the `node_modules` link,
45
+ `.vscode/settings.json`, the pin-rebase targets, and the record refresh at its already-journaled
46
+ path), leaf-only (files and symlinks; directories are containers, never proof obligations) and
47
+ kind-gated: membership requires the live node's kind to match what that lane's SOURCE places, so
48
+ a directory, a special node, or a kind-mismatched link at an owned path is session content the
49
+ verifier never probes. The journal FREEZES at the verify. Lane probes are LITERAL — pathspec
50
+ magic in a placed name is inert — and tracked-priority (tracked > ignored > untracked). An
51
+ untracked owned leaf STOPs with the convergent fix first: restore the ignore rule (the only
52
+ convergent fix for a mandatory copy-set leaf, the `node_modules` link, and
53
+ `.vscode/settings.json` — each is placed only where a check-ignore gate proved the destination
54
+ ignored, and removing the node is non-convergent because the next resume re-places it). A
55
+ droppable `--include` instead gets ONE grouped instruction on its destination ROOT — dropping the
56
+ flag orphans every copy under that root, since cleanup derives ownership from the recorded
57
+ includes — namely: salvage or relocate the whole root OUT of the worktree AND drop
58
+ `--include <root>` in the same run, either alone recurs. No removal is ever derived there: the
59
+ journal cannot see session content or kind-excluded nodes inside that root, so no `rm` over it
60
+ could be proven safe; and leaving it in place is not offered either, because an orphaned
61
+ destination is exactly what stops land. A kept node is never advised away, an excluded dir
62
+ (`docs/plans`, `docs/ai`) never gets a tracking arm, and a probe error carries NO recovery
63
+ command. The tracked lane is proven by a byte-exact path match, never by a non-empty listing — a
64
+ pathspec naming a directory lists its descendants, which prove nothing about the path itself. Membership itself is kind-gated only for a KEPT node: a
65
+ node THIS attempt created is owned by construction and stays in the proof set whatever its live
66
+ kind became. So a `--resume` in a worked-in satellite completes
67
+ with the session's uncommitted tracked edits, untracked scratch, and hook-created files
68
+ untouched — while the FIRST provision stays deliberately stricter, refusing any dirt at the
69
+ blanket clean-tree verify (its untracked visibility is explicit, so repo `status` configuration
70
+ cannot blind it).
41
71
  `--include` sources are identity-bound: preflight records each include root's identity (device,
42
72
  inode, and kind of the canonical node) BEFORE `git worktree add`, and a root that is neither a
43
73
  regular file nor a directory — or whose identity probe fails — is refused before any mutation.
@@ -98,8 +128,10 @@ own verbatim error through the existing Git-error surface.
98
128
  Foreign content stops cleanup. `--abandon` is the ONE destructive arm: it DESTROYS unlanded work,
99
129
  requires the handoff identity, and is the only path where `--force` may appear.
100
130
 
101
- **Provision record (`docs/plans/handoff-<slug>.md`, `## Provision record` — tool-owned):** identity
102
- (`slug`, `branch`, `include`, `node_modules`, `vscode-settings`, and after a prepare `prepared-tree`)
131
+ **Provision record (`docs/plans/handoff-<slug>.md`, `## Provision record` — tool-owned):** resume
132
+ IDENTITY (`slug`, `branch`, and the seeded plan name a mismatch STOPs) · recorded provision FACTS
133
+ that never authorize a resume (`include`, `node_modules`, `vscode-settings`) · and, after a prepare,
134
+ `prepared-tree`, which is a land/cleanup attestation-and-recovery surface, not resume identity —
103
135
  PLUS the three facts a fresh satellite session cannot derive from its own checkout:
104
136
 
105
137
  - `shared-queue` — the ABSOLUTE path to MAIN's `docs/plans/queue.md`, followed by the rule the record states verbatim: the series index is SHARED and lives ONLY in main: read it at the absolute path above, and never copy it into this worktree, because docs/plans is git-ignored and machine-local, so a copy silently diverges from what main and every other worktree are writing. This worktree never WRITES that file: reaching outside it is an fs_outside_repo action the autonomy policy denies by default. Put new findings in THIS handoff record instead — it is the channel that survives the landing, and main appends them to the index from here. Provision never seeds a copy: the queue is deliberately absent from the satellite, and the absolute path is the only pointer — `--include` refuses to copy the index (or any directory containing it) into the worktree.
@@ -109,9 +141,9 @@ PLUS the three facts a fresh satellite session cannot derive from its own checko
109
141
  isolated-install command when the package manager is unambiguous, the honest install-by-hand
110
142
  advice when it is not, and — when the provisioned `node_modules` is a SYMLINK into main — the
111
143
  unlink-first form, because a plain install through the symlink writes into MAIN and is never
112
- presented as isolated. When the WORKTREE'S OWN LIVE CHECKOUT is provably dependency-free (its `package.json` declares no dependencies, no `workspaces` field of any shape, no install-lifecycle script, no native-addon manifest, no external workspace manifest beside it — the evidence is what an install run in the satellite would actually read: the checkout's LIVE files at the moment the posture is resolved; on `--resume` a dirty tree is then refused by the clean-tree verify, before the record refresh — a failed resume leaves the prior record bytes) the record and the default-lane report both state `no install needed — the project declares no dependencies` and print no install command. A workspace tree is NEVER provably install-free — a workspace install materializes member links and `.bin` shims even with zero dependencies — and anything else the tool cannot enumerate (an absent or unparseable `package.json`, a malformed dependency or scripts field, an install-lifecycle script — dependency-free is not install-free) leaves the posture UNKNOWN and keeps the existing advice: a false "nothing to install" is worse than a redundant hint. `--install` remains an EXPLICIT request and is always answered with the
144
+ presented as isolated. When the WORKTREE'S OWN LIVE CHECKOUT is provably dependency-free (its `package.json` declares no dependencies, no `workspaces` field of any shape, no install-lifecycle script, no native-addon manifest, no external workspace manifest beside it — the evidence is what an install run in the satellite would actually read: the checkout's LIVE files at the moment the posture is resolved; on `--resume` that includes the session's own uncommitted edits, which the per-owned-path verify tolerates, and a failed resume leaves the prior record bytes) the record and the default-lane report both state `no install needed — the project declares no dependencies` and print no install command. A workspace tree is NEVER provably install-free — a workspace install materializes member links and `.bin` shims even with zero dependencies — and anything else the tool cannot enumerate (an absent or unparseable `package.json`, a malformed dependency or scripts field, an install-lifecycle script — dependency-free is not install-free) leaves the posture UNKNOWN and keeps the existing advice: a false "nothing to install" is worse than a redundant hint. `--install` remains an EXPLICIT request and is always answered with the
113
145
  isolated-install command.
114
- All manifest/lockfile install evidence — the dependency-free proof AND the package-manager selection (the `packageManager` field, lockfiles) — is read from the worktree's own LIVE files at the moment the posture is resolved (on `--resume` too, where a dirty tree is then refused by the clean-tree verify); MAIN's mutable working tree never steers manager selection.
146
+ All manifest/lockfile install evidence — the dependency-free proof AND the package-manager selection (the `packageManager` field, lockfiles) — is read from the worktree's own LIVE files at the moment the posture is resolved (on `--resume` too, over the session's own live edits); MAIN's mutable working tree never steers manager selection.
115
147
 
116
148
  **Honesty:** there is NO preview step on the writers — over-warned by design. The tool never
117
149
  commits, never pushes, never runs a subscription CLI. Every content read and regular-file copy
@@ -34,7 +34,7 @@ import {
34
34
  } from './recommendations.mjs';
35
35
  import { SKIPPED_READONLY } from './setup-backends.mjs';
36
36
  import { LATENT_ARM_NOTICE } from './review-state.mjs';
37
- import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN, NO_DEPENDENCIES_POSTURE, CLEANUP_OWNERSHIP_RULE, INCLUDE_IDENTITY_RULE } from './worktrees.mjs';
37
+ import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN, NO_DEPENDENCIES_POSTURE, CLEANUP_OWNERSHIP_RULE, INCLUDE_IDENTITY_RULE, RESUME_VERIFY_RULE } from './worktrees.mjs';
38
38
 
39
39
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
40
40
 
@@ -112,6 +112,10 @@ export const BINDINGS = Object.freeze([
112
112
  // a reworded mode doc dropping the preflight-binding × door-time-queue contract fails this pin
113
113
  // plus the gate.
114
114
  valueBinding('include-identity-rule', INCLUDE_IDENTITY_RULE, INCLUDE_IDENTITY_RULE, [WORKTREES_DOC]),
115
+ // The resume-verify contract (slice R2): the exact live sentence every resume-verify STOP emits —
116
+ // a reworded mode doc dropping the per-owned-path × session-never-probed contract fails this pin
117
+ // plus the gate.
118
+ valueBinding('resume-verify-rule', RESUME_VERIFY_RULE, RESUME_VERIFY_RULE, [WORKTREES_DOC]),
115
119
  ].map((b) => Object.freeze(b)));
116
120
 
117
121
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -160,7 +164,8 @@ table, the status tokens, the trusted-dir allowlist), the recommendations/upgrad
160
164
  contract (section header, empty line, verdict templates), the acks-store path, the setup refresh
161
165
  degrade token, the review-state clean-tree latent-arm notice, the worktrees provision-record
162
166
  orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture), the
163
- worktrees cleanup-ownership rule, and the worktrees include-identity rule to
167
+ worktrees cleanup-ownership rule, the worktrees include-identity rule, and the worktrees
168
+ resume-verify rule — to
164
169
  the exact token its references/modes/*.md contract must carry, and
165
170
  asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
166
171
  or an absent token FAILS CLOSED.
@@ -785,7 +785,9 @@ const failAfterCopy = ({ cause, dstAbs, wtRoot, fs }) => {
785
785
  throw stop(`${primary} — partial destination removed; re-run provision`);
786
786
  };
787
787
 
788
- const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null }) => {
788
+ const NO_JOURNAL = Object.freeze({ record: () => {} });
789
+
790
+ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null, journal = NO_JOURNAL, surface = 'copy-set-leaf', journalRoot = null }) => {
789
791
  if (EXCLUDED_BASENAMES.has(basename(srcAbs))) {
790
792
  report.push(` skip (session sidecar): ${rel}`);
791
793
  return;
@@ -809,6 +811,7 @@ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null
809
811
  // Fresh-provision include lane: an existing destination is aliasing the overlap
810
812
  // comparator missed (nothing legitimate pre-populates it) — fail closed, never "kept".
811
813
  if (door?.fresh) throw includeIdentityStop(rel, INCLUDE_PREEXIST_CAUSE);
814
+ journal.record({ rel, surface, outcome: 'kept', kind: 'symlink', root: journalRoot });
812
815
  report.push(` kept (already present): ${rel}`);
813
816
  return;
814
817
  }
@@ -842,6 +845,7 @@ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null
842
845
  guardDst(fs, wtRoot, dstAbs);
843
846
  fs.symlink(target, dstAbs);
844
847
  copied.add(rel);
848
+ journal.record({ rel, surface, outcome: 'written', kind: 'symlink', root: journalRoot });
845
849
  report.push(` linked: ${rel} -> ${target}`);
846
850
  } else if (st.isDirectory()) {
847
851
  if (lstatNoFollow(fs.lstat, dstAbs) === null) {
@@ -854,11 +858,12 @@ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null
854
858
  throw includeIdentityStop(rel, INCLUDE_PREEXIST_CAUSE);
855
859
  }
856
860
  for (const entry of fs.readdir(srcAbs)) {
857
- copyNode({ srcAbs: join(srcAbs, entry), dstAbs: join(dstAbs, entry), wtRoot, rel: `${rel}/${entry}`, fs, report, copied, door });
861
+ copyNode({ srcAbs: join(srcAbs, entry), dstAbs: join(dstAbs, entry), wtRoot, rel: `${rel}/${entry}`, fs, report, copied, door, journal, surface, journalRoot });
858
862
  }
859
863
  } else if (st.isFile()) {
860
864
  if (lstatNoFollow(fs.lstat, dstAbs) !== null) {
861
865
  if (door?.fresh) throw includeIdentityStop(rel, INCLUDE_PREEXIST_CAUSE);
866
+ journal.record({ rel, surface, outcome: 'kept', root: journalRoot });
862
867
  report.push(` kept (already present): ${rel}`);
863
868
  return;
864
869
  }
@@ -870,6 +875,7 @@ const copyNode = ({ srcAbs, dstAbs, wtRoot, rel, fs, report, copied, door = null
870
875
  failAfterCopy({ cause, dstAbs, wtRoot, fs });
871
876
  }
872
877
  copied.add(rel);
878
+ journal.record({ rel, surface, outcome: 'written', root: journalRoot });
873
879
  report.push(` copied: ${rel}`);
874
880
  } else {
875
881
  throw stop(`refusing to copy a special file (device/FIFO/socket): ${rel}`);
@@ -1292,8 +1298,139 @@ export const NODE_MODULES_NONE = 'no-dependencies';
1292
1298
  // cleanup time — never provenance, never the handoff record. Doc-parity pins this exact sentence
1293
1299
  // into the worktrees mode doc; every ownership STOP emits it.
1294
1300
  export const CLEANUP_OWNERSHIP_RULE = "node_modules ownership is decided live: only a symlink whose raw target bytes equal MAIN's node_modules path, in the ignored lane, is provision-ephemeral; an absent node with no index entry is clean; every other state stops cleanup to protect user data or because inspection failed";
1301
+ export const RESUME_VERIFY_RULE = "the resume verify proves only what THIS run placed or kept: every journaled leaf must be tracked or ignored in the worktree, an untracked owned leaf or any probe error stops the run naming the exact leaf, and every other path — the session's own work — is never probed and never a stop cause; a first provision keeps the blanket clean-tree verify";
1295
1302
  export const INCLUDE_IDENTITY_RULE = 'An --include source is copied only through the identity door: a file include must still match the identity preflight recorded (device, inode, kind), a directory include root is re-checked at walk start, and every copied file is proven, with both descriptors open, not to be the node that IS the door-time queue — an absent queue keeps the lexical guard alone, and anything unprovable stops the copy';
1296
1303
 
1304
+ // ── the placement journal (slice R2) ───────────────────────────────────────────────────
1305
+ // The resume verify proves PER PLACED PATH, so it needs a proof list: this run's live placement
1306
+ // journal over a CLOSED-WORLD registry — the surfaces are enumerated POSITIVELY, so no universal
1307
+ // "every mutation" claim exists to puncture. Leaf-only (git tracks no directories), KIND-GATED (a
1308
+ // live node whose kind differs from what the lane's SOURCE places is the pre-existing kept-exit
1309
+ // residual — SESSION for the verifier), and FROZEN at the verify: the sole post-verify write, the
1310
+ // record refresh, is permitted only at its already-journaled path.
1311
+ export const PLACEMENT_REGISTRY = Object.freeze([
1312
+ 'handoff-stub',
1313
+ 'seed-plan',
1314
+ 'copy-set-leaf',
1315
+ 'include-leaf',
1316
+ 'node-modules-link',
1317
+ 'vscode-settings',
1318
+ 'pin-rebase-target',
1319
+ 'record-refresh',
1320
+ ]);
1321
+ const PLACEMENT_SURFACES = new Set(PLACEMENT_REGISTRY);
1322
+ // The ONE droppable class: an --include destination may be salvaged/relocated together with
1323
+ // dropping its source. Every other surface is mandatory — the next resume re-places it, so removal
1324
+ // is not a convergent fix and is never advised.
1325
+ const DROPPABLE_SURFACES = new Set(['include-leaf']);
1326
+
1327
+ const journalKindMatches = (fs, abs, kind) => {
1328
+ const st = lstatNoFollow(fs.lstat, abs);
1329
+ if (st === null) return false;
1330
+ return kind === 'symlink' ? st.isSymbolicLink() : !st.isSymbolicLink() && st.isFile();
1331
+ };
1332
+
1333
+ export const createPlacementJournal = ({ wtRoot, fs }) => {
1334
+ const members = new Map();
1335
+ const state = { frozen: false };
1336
+ return {
1337
+ record: ({ rel, surface, outcome, kind = 'file', root = null }) => {
1338
+ if (!PLACEMENT_SURFACES.has(surface)) {
1339
+ throw stop(`placement journal: "${surface}" is not a registry surface — the placement registry is closed`);
1340
+ }
1341
+ if (state.frozen) {
1342
+ if (!members.has(rel)) {
1343
+ throw stop(`placement journal: refusing a post-verify write at an unjournaled path: ${rel}`);
1344
+ }
1345
+ return;
1346
+ }
1347
+ // The kind gate is the KEPT-outcome residual only. A node THIS attempt created is owned by
1348
+ // construction: dropping it here because its kind changed after the write would leave a path
1349
+ // provision just placed unproven — the opposite of the fail-safe floor.
1350
+ if (outcome !== 'written' && !journalKindMatches(fs, join(wtRoot, rel), kind)) return;
1351
+ const entry = { rel, surface, outcome, ...(root === null ? {} : { root }) };
1352
+ const prior = members.get(rel);
1353
+ if (prior === undefined) members.set(rel, entry);
1354
+ else if (outcome === 'written' && prior.outcome !== 'written') members.set(rel, { ...prior, outcome: 'written' });
1355
+ },
1356
+ freeze: () => {
1357
+ state.frozen = true;
1358
+ return [...members.values()];
1359
+ },
1360
+ };
1361
+ };
1362
+
1363
+ // The per-owned-path lane probe, LITERAL by construction (D11) — live-probed against git 2.43:
1364
+ // `ls-files` accepts an explicit `:(literal)` pathspec, but `check-ignore` REFUSES pathspec magic
1365
+ // outright ("pathspec magic not supported by this command") AND, with the index in play, answers
1366
+ // for a name that GLOB-matches a tracked sibling — a file literally named `feature-[a].md` reads
1367
+ // as "not ignored" once `feature-a.md` is tracked. `--no-index` removes that shadow, leaving a
1368
+ // pure ignore-rule match. Tracked priority stays a VERSION-INDEPENDENT invariant precisely because
1369
+ // this probe decides it FIRST, on its own literal pathspec, instead of leaning on whatever
1370
+ // index-awareness a given git version bakes into `check-ignore`.
1371
+ const probeOwnedLane = ({ git, wtRoot, rel }) => {
1372
+ const tracked = git(['ls-files', '-z', '--', literalPathspec(rel)], wtRoot);
1373
+ if (tracked.status !== 0) {
1374
+ return { lane: 'probe-error', detail: `git ls-files failed: ${(tracked.stderr || tracked.stdout).trim()}` };
1375
+ }
1376
+ // A non-empty result is NOT proof: live-probed on git 2.43, a pathspec naming a DIRECTORY lists
1377
+ // its tracked DESCENDANTS (`:(literal)notes` → notes/note.md …). Only a field byte-equal to the
1378
+ // probed path proves THIS path tracked; anything else falls through to the ignore/untracked
1379
+ // proof, so a non-leaf member fails closed instead of passing on a descendant's back.
1380
+ if (nulFields(tracked.stdout).includes(rel)) return { lane: 'tracked' };
1381
+ const ignored = git(['check-ignore', '--no-index', '--', rel], wtRoot);
1382
+ if (ignored.status === 0) return { lane: 'ignored' };
1383
+ if (ignored.status === 1) return { lane: 'untracked' };
1384
+ return { lane: 'probe-error', detail: `git check-ignore failed: ${(ignored.stderr || ignored.stdout).trim()}` };
1385
+ };
1386
+
1387
+ // Recovery for a DROPPABLE surface is emitted ONCE per include ROOT, never per leaf: dropping the
1388
+ // flag orphans every copy under that root (cleanup derives its ownership from `record.includes`),
1389
+ // so leaf-only advice cannot converge. It never offers REMOVAL: the journal cannot see session
1390
+ // content or kind-excluded nodes sitting inside that root, so no derived `rm` could be proven safe.
1391
+ // It also never says "leave it here": an orphaned destination is exactly what stops land — the
1392
+ // convergent action is moving the whole root OUT of the worktree.
1393
+ const droppableRootRecovery = (root) =>
1394
+ ` ${root}: salvage or relocate the whole include destination root OUT of the worktree — its contents are preserved wherever you move them — AND drop \`--include ${root}\` in the same run; either alone recurs (a remaining source re-creates the copy, and an orphaned destination stops land)`;
1395
+
1396
+ const ownedRecoveryLines = (failures) => {
1397
+ const lines = [];
1398
+ const seenRoots = new Set();
1399
+ for (const failure of failures) {
1400
+ if (DROPPABLE_SURFACES.has(failure.surface) && failure.root) {
1401
+ if (seenRoots.has(failure.root)) continue;
1402
+ seenRoots.add(failure.root);
1403
+ lines.push(droppableRootRecovery(failure.root));
1404
+ continue;
1405
+ }
1406
+ lines.push(` ${failure.rel}: restore the ignore rule covering it in this worktree (.gitignore or the shared exclude), then re-run --resume`);
1407
+ }
1408
+ return lines;
1409
+ };
1410
+
1411
+ // A set containing an unprovable lane withholds EVERY recovery command: advice derived from a
1412
+ // half-read tree is worse than none (the R1 mixed-findings discipline).
1413
+ const composeOwnedVerifyStop = (failures) => [
1414
+ 'post-provision verify failed — provision cannot prove the git lane of a path it placed or kept:',
1415
+ ...failures.map(({ rel, surface, outcome, lane, detail }) => (lane === 'probe-error'
1416
+ ? ` ${rel} (${surface}, ${outcome}) — lane unprovable: ${detail}`
1417
+ : ` ${rel} (${surface}, ${outcome}) — untracked`)),
1418
+ ...(failures.some((f) => f.lane === 'probe-error')
1419
+ ? ['No recovery command is offered: a lane probe failed, so the tree state is unproven.']
1420
+ : ['Recovery (convergent — through land preflight, not merely the next resume):', ...ownedRecoveryLines(failures)]),
1421
+ RESUME_VERIFY_RULE,
1422
+ ].join('\n');
1423
+
1424
+ const verifyPlacedPaths = ({ git, wtRoot, members }) => {
1425
+ const failures = [];
1426
+ for (const member of members) {
1427
+ const probe = probeOwnedLane({ git, wtRoot, rel: member.rel });
1428
+ if (probe.lane === 'tracked' || probe.lane === 'ignored') continue;
1429
+ failures.push({ ...member, ...probe });
1430
+ }
1431
+ if (failures.length > 0) throw stop(composeOwnedVerifyStop(failures));
1432
+ };
1433
+
1297
1434
  // The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
1298
1435
  // is refused rather than written: a newline spills a second line the parser reads as a real field
1299
1436
  // (`- include:` is exempt from the duplicate-identity STOP, and an `## …` spill truncates or bricks
@@ -1454,10 +1591,12 @@ const pendingHandoffFields = ({ root, slug, branch }) =>
1454
1591
  ({ slug, branch, includes: [], nodeModules: 'pending', vscode: 'pending', install: 'pending', ...orientationFields({ root, slug }) });
1455
1592
 
1456
1593
  // The stub is written only when ABSENT; the final record surgically replaces the tool section.
1457
- const writeHandoffStubIfAbsent = ({ root, wtRoot, slug, branch, fs, report }) => {
1594
+ const writeHandoffStubIfAbsent = ({ root, wtRoot, slug, branch, fs, report, journal = NO_JOURNAL }) => {
1595
+ const rel = `${PLANS_REL}/${handoffBasename(slug)}`;
1458
1596
  const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
1459
1597
  const cur = readFileNoFollow(fs, dst);
1460
1598
  if (cur.bytes) {
1599
+ journal.record({ rel, surface: 'handoff-stub', outcome: 'kept' });
1461
1600
  report.push(' handoff: kept (already present)');
1462
1601
  return;
1463
1602
  }
@@ -1467,14 +1606,18 @@ const writeHandoffStubIfAbsent = ({ root, wtRoot, slug, branch, fs, report }) =>
1467
1606
  guardDst(fs, wtRoot, dirname(dst));
1468
1607
  fs.mkdir(dirname(dst));
1469
1608
  writeContainedFileAtomic(wtRoot, dst, composeHandoffStub(pendingHandoffFields({ root, slug, branch })), fs, { stop: (m) => stop(m) });
1609
+ journal.record({ rel, surface: 'handoff-stub', outcome: 'written' });
1470
1610
  };
1471
1611
 
1472
- const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report }) => {
1612
+ const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report, journal = NO_JOURNAL }) => {
1473
1613
  const dst = join(wtRoot, PLANS_REL, handoffBasename(slug));
1474
1614
  const cur = readFileNoFollow(fs, dst);
1475
1615
  if (!cur.bytes) {
1476
1616
  throw stop(`the handoff at ${PLANS_REL}/${handoffBasename(slug)} is not readable as a regular file — fix or remove it, then re-run --resume`);
1477
1617
  }
1618
+ // The freeze lock, checked AFTER the content door so a node problem keeps its own precise error:
1619
+ // this is the ONLY post-verify write, and only at the path the stub already journaled.
1620
+ journal.record({ rel: `${PLANS_REL}/${handoffBasename(slug)}`, surface: 'record-refresh', outcome: 'kept' });
1478
1621
  const section = locateProvisionRecordSection(String(cur.bytes));
1479
1622
  const updated = `${section.source.slice(0, section.start)}${composeProvisionRecordSection(fields)}${section.source.slice(section.end)}`;
1480
1623
  writeContainedFileAtomic(wtRoot, dst, updated, fs, { stop: (m) => stop(m) });
@@ -1518,9 +1661,10 @@ const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
1518
1661
  return { srcAbs: srcReal, name };
1519
1662
  };
1520
1663
 
1521
- const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
1664
+ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report, journal = NO_JOURNAL }) => {
1522
1665
  const dst = join(wtRoot, PLANS_REL, name);
1523
1666
  if (lstatNoFollow(fs.lstat, dst) !== null) {
1667
+ journal.record({ rel: `${PLANS_REL}/${name}`, surface: 'seed-plan', outcome: 'kept' });
1524
1668
  report.push(` kept (already present): ${PLANS_REL}/${name}`);
1525
1669
  return;
1526
1670
  }
@@ -1529,6 +1673,7 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
1529
1673
  guardDst(fs, wtRoot, dirname(dst));
1530
1674
  fs.mkdir(dirname(dst));
1531
1675
  writeContainedFileAtomic(wtRoot, dst, String(src.bytes), fs, { stop: (m) => stop(m) });
1676
+ journal.record({ rel: `${PLANS_REL}/${name}`, surface: 'seed-plan', outcome: 'written' });
1532
1677
  report.push(` seeded plan: ${PLANS_REL}/${name}`);
1533
1678
  };
1534
1679
 
@@ -1537,7 +1682,7 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report }) => {
1537
1682
  // It is copied from that already-canonical `real`, NEVER re-resolved from the raw path: a fresh
1538
1683
  // realpath here (after the worktree exists) would re-open a TOCTOU where a swapped symlink could
1539
1684
  // redirect an include at the shared series index between the check and the copy.
1540
- const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs, report, copied }) => {
1685
+ const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs, report, copied, journal = NO_JOURNAL }) => {
1541
1686
  const recorded = [];
1542
1687
  const queuePath = join(rootReal, PLANS_REL, QUEUE_BASENAME);
1543
1688
  for (const { rel, real, identity } of includeSources) {
@@ -1575,7 +1720,7 @@ const provisionIncludes = ({ rootReal, wtRoot, includeSources, resume, git, fs,
1575
1720
  const door = identity.kind === 'file'
1576
1721
  ? { identity, queuePath, fresh: !resume }
1577
1722
  : { queuePath, fresh: !resume };
1578
- copyNode({ srcAbs: real, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied, door });
1723
+ copyNode({ srcAbs: real, dstAbs: join(wtRoot, rel), wtRoot, rel, fs, report, copied, door, journal, surface: 'include-leaf', journalRoot: rel });
1579
1724
  recorded.push(rel);
1580
1725
  }
1581
1726
  return recorded;
@@ -1744,14 +1889,18 @@ const resolveInstallPosture = ({ wtRoot, dependencyFree, fs }) => {
1744
1889
  return resolveInstallAdvice({ wtRoot, fs }).instruction;
1745
1890
  };
1746
1891
 
1747
- const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report }) => {
1892
+ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report, journal = NO_JOURNAL }) => {
1893
+ // The lane places ONLY a symlink, so the kind gate admits only a symlink at this path: a
1894
+ // directory (a real install) is the user's, never provision's to prove or advise on.
1895
+ const journalLink = (outcome) => journal.record({ rel: NODE_MODULES_REL, surface: 'node-modules-link', outcome, kind: 'symlink' });
1748
1896
  const install = resolveInstallAdvice({ wtRoot, fs });
1749
1897
  if (installFlag) {
1750
- const dst = join(wtRoot, 'node_modules');
1898
+ const dst = join(wtRoot, NODE_MODULES_REL);
1751
1899
  const existing = lstatNoFollow(fs.lstat, dst);
1752
1900
  if (existing !== null && existing.isSymbolicLink()) {
1753
1901
  // isolation only exists BEFORE the link: an install through it would write into MAIN
1754
1902
  const separator = install.command === null ? ' — ' : ' && ';
1903
+ journalLink('kept');
1755
1904
  report.push(` node_modules: existing symlink kept — for isolation remove it first: rm ${shellQuoteArg(dst)}${separator}${install.instruction}`);
1756
1905
  return 'install-printed-unlink-first';
1757
1906
  }
@@ -1763,8 +1912,9 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyF
1763
1912
  // LIVE STATE WINS the whole default lane: a node already at the worktree — a directory, or a
1764
1913
  // symlink an earlier provision left, even dangling — is what the record states; reporting
1765
1914
  // MAIN's state (`absent`) beside an existing node would contradict record.install.
1766
- const dst = join(wtRoot, 'node_modules');
1915
+ const dst = join(wtRoot, NODE_MODULES_REL);
1767
1916
  if (lstatNoFollow(fs.lstat, dst) !== null) {
1917
+ journalLink('kept');
1768
1918
  report.push(' node_modules: already present in the worktree');
1769
1919
  return 'present';
1770
1920
  }
@@ -1816,12 +1966,20 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyF
1816
1966
  report.push(` node_modules: symlink failed (${err?.code ?? 'error'}) — ${install.instruction}`);
1817
1967
  return 'symlink-failed';
1818
1968
  }
1969
+ journalLink('written');
1819
1970
  report.push(` node_modules: symlinked -> ${mainNm} (shared MUTABLE cache — writes through it hit MAIN's node_modules; isolation: --install; workspace self-links resolve to MAIN sources)`);
1820
1971
  return 'symlinked';
1821
1972
  };
1822
1973
 
1823
- const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
1974
+ const provisionVscode = ({ root, wtRoot, slug, git, fs, report, journal = NO_JOURNAL }) => {
1824
1975
  const relPath = '.vscode/settings.json';
1976
+ // An EXISTING satellite destination is journaled FIRST, before every source-side and gate-side
1977
+ // early return: the doors decide only what this run WRITES, while the journal decides what gets
1978
+ // PROVEN. Membership must not depend on MAIN's current state — a file an earlier run placed
1979
+ // would otherwise skip here (MAIN lost its .vscode dir, MAIN's copy became tracked, or the
1980
+ // ignore rule was lost) and ride a successful resume out as a land-blocking leftover.
1981
+ const present = lstatNoFollow(fs.lstat, join(wtRoot, relPath)) !== null;
1982
+ if (present) journal.record({ rel: relPath, surface: 'vscode-settings', outcome: 'kept' });
1825
1983
  const vscodeDir = lstatNoFollow(fs.lstat, join(root, '.vscode'));
1826
1984
  if (vscodeDir === null || !vscodeDir.isDirectory()) {
1827
1985
  report.push(' .vscode: main has no .vscode/ dir — window title not written');
@@ -1839,7 +1997,7 @@ const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
1839
1997
  report.push(` .vscode: ${relPath} is not ignored in the worktree — skipped (it would become a land leftover)`);
1840
1998
  return 'skipped-not-ignored';
1841
1999
  }
1842
- if (lstatNoFollow(fs.lstat, join(wtRoot, relPath)) !== null) {
2000
+ if (present) {
1843
2001
  report.push(' .vscode: kept (already present)');
1844
2002
  return 'kept';
1845
2003
  }
@@ -1868,6 +2026,7 @@ const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
1868
2026
  guardDst(fs, wtRoot, join(wtRoot, '.vscode'));
1869
2027
  fs.mkdir(join(wtRoot, '.vscode'));
1870
2028
  writeContainedFileAtomic(wtRoot, join(wtRoot, relPath), body, fs, { stop: (m) => stop(m) });
2029
+ journal.record({ rel: relPath, surface: 'vscode-settings', outcome: 'written' });
1871
2030
  report.push(` .vscode: ${relPath} written (window.title = ${slug})`);
1872
2031
  return 'written';
1873
2032
  };
@@ -1875,11 +2034,12 @@ const provisionVscode = ({ root, wtRoot, slug, git, fs, report }) => {
1875
2034
  // tracked/untracked is decided by GIT (a run-local copy log lies after a crash-resume); an
1876
2035
  // untracked pin-carrying file is rewritten ONLY when its bytes equal the MAIN source or its
1877
2036
  // already-rebased form — anything else is user work and stays byte-untouched (reported).
1878
- const rebasePins = ({ root, wtRoot, git, fs, report }) => {
2037
+ const rebasePins = ({ root, wtRoot, git, fs, report, journal = NO_JOURNAL }) => {
1879
2038
  for (const target of REBASE_TARGETS) {
1880
2039
  const wtAbs = join(wtRoot, target);
1881
2040
  const cur = readFileNoFollow(fs, wtAbs);
1882
2041
  if (cur.absent) continue;
2042
+ if (cur.bytes) journal.record({ rel: target, surface: 'pin-rebase-target', outcome: 'kept' });
1883
2043
  if (!cur.bytes) {
1884
2044
  report.push(` ${target}: ${cur.unsafe ? 'not a regular file' : `unreadable (${cur.error})`} — left untouched`);
1885
2045
  continue;
@@ -2069,24 +2229,27 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
2069
2229
  };
2070
2230
 
2071
2231
  const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log }) => {
2072
- writeHandoffStubIfAbsent({ root, wtRoot: targetPath, slug, branch, fs, report });
2232
+ // THIS run's proof set: every lane journals the leaf it placed or kept, and nothing else is ever
2233
+ // examined by the resume verify — the session's own work is out of scope by construction.
2234
+ const journal = createPlacementJournal({ wtRoot: targetPath, fs });
2235
+ writeHandoffStubIfAbsent({ root, wtRoot: targetPath, slug, branch, fs, report, journal });
2073
2236
 
2074
2237
  const copied = new Set();
2075
2238
  report.push('copying the provision set (copy-if-missing; tracked files come from the checkout):');
2076
2239
  for (const pattern of provisionSet) {
2077
2240
  const rel = patternToProbe(pattern).replace(/\/$/, '');
2078
- copyNode({ srcAbs: join(root, rel), dstAbs: join(targetPath, rel), wtRoot: targetPath, rel, fs, report, copied });
2241
+ copyNode({ srcAbs: join(root, rel), dstAbs: join(targetPath, rel), wtRoot: targetPath, rel, fs, report, copied, journal });
2079
2242
  }
2080
2243
 
2081
- writeSeedPlan({ wtRoot: targetPath, srcAbs: seed.srcAbs, name: seed.name, fs, report });
2082
- const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, resume: flags.resume, git, fs, report, copied });
2244
+ writeSeedPlan({ wtRoot: targetPath, srcAbs: seed.srcAbs, name: seed.name, fs, report, journal });
2245
+ const includesRecorded = provisionIncludes({ rootReal, wtRoot: targetPath, includeSources, resume: flags.resume, git, fs, report, copied, journal });
2083
2246
  // Computed ONCE, from the satellite's own checkout, and threaded to both consumers — the report
2084
2247
  // lane and the record must state the SAME verdict.
2085
2248
  const dependencyFree = declaresNoDependencies({ wtRoot: targetPath, fs });
2086
- const nodeModulesMode = provisionNodeModules({ root, rootReal, wtRoot: targetPath, installFlag: flags.install, dependencyFree, git, fs, report });
2087
- const vscodeMode = provisionVscode({ root, wtRoot: targetPath, slug, git, fs, report });
2249
+ const nodeModulesMode = provisionNodeModules({ root, rootReal, wtRoot: targetPath, installFlag: flags.install, dependencyFree, git, fs, report, journal });
2250
+ const vscodeMode = provisionVscode({ root, wtRoot: targetPath, slug, git, fs, report, journal });
2088
2251
 
2089
- rebasePins({ root, wtRoot: targetPath, git, fs, report });
2252
+ rebasePins({ root, wtRoot: targetPath, git, fs, report, journal });
2090
2253
 
2091
2254
  const inFlight = plansInFlight(targetPath, fs.readdir);
2092
2255
  if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
@@ -2095,12 +2258,24 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2095
2258
  );
2096
2259
  }
2097
2260
 
2098
- const porcelain = git(['status', '--porcelain'], targetPath);
2099
- if (porcelain.status !== 0) throw stop(`git status failed in the worktree: ${porcelain.stderr.trim()}`);
2100
- if (porcelain.stdout.trim() !== '') {
2101
- throw stop(
2102
- `post-provision verify failed the worktree status is not clean (everything provision places must be ignored-or-tracked):\n${porcelain.stdout.trimEnd()}`,
2103
- );
2261
+ // The journal FREEZES here: the verify is the boundary, and the only write past it — the record
2262
+ // refresh is a registry surface permitted solely at its already-journaled path.
2263
+ const placed = journal.freeze();
2264
+ if (flags.resume) {
2265
+ // The resume lane proves PER OWNED PATH. No `git status` runs here at all: the session's work
2266
+ // is out of scope by construction, not by subtraction.
2267
+ verifyPlacedPaths({ git, wtRoot: targetPath, members: placed });
2268
+ } else {
2269
+ // `--untracked-files=normal` is EXPLICIT: `status.showUntrackedFiles=no` empties porcelain
2270
+ // output, which would silently turn this strict verify into a no-op. Default behavior is
2271
+ // unchanged — `normal` IS the default shape.
2272
+ const porcelain = git(['status', '--porcelain', '--untracked-files=normal'], targetPath);
2273
+ if (porcelain.status !== 0) throw stop(`git status failed in the worktree: ${porcelain.stderr.trim()}`);
2274
+ if (porcelain.stdout.trim() !== '') {
2275
+ throw stop(
2276
+ `post-provision verify failed — the worktree status is not clean (everything provision places must be ignored-or-tracked):\n${porcelain.stdout.trimEnd()}`,
2277
+ );
2278
+ }
2104
2279
  }
2105
2280
 
2106
2281
  // The record refresh runs LAST, after the in-flight check and the verify, in BOTH lanes —
@@ -2113,6 +2288,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2113
2288
  wtRoot: targetPath,
2114
2289
  slug,
2115
2290
  branch,
2291
+ journal,
2116
2292
  fields: {
2117
2293
  slug,
2118
2294
  branch,