@sabaiway/agent-workflow-kit 3.9.0 → 3.11.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,64 @@ 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.11.0 — the record attests only a verified provision; tracked plans-chain paths refuse (AD-072)
8
+
9
+ Two provision honesty fixes from the converged resume-verify design (its slice R1; the
10
+ tolerance flip is the next slice):
11
+
12
+ - **The provision record is refreshed LAST — after the in-flight-plan check and the
13
+ post-provision verify, in BOTH lanes.** The record now attests only a VERIFIED provision: a
14
+ first provision that fails after the stub write leaves the STUB (identity still binds from
15
+ it — `--resume` completes as before; a pre-write refusal leaves no handoff at all), and a
16
+ failed resume leaves the PRIOR record bytes byte-exact. Previously a failed
17
+ run left a freshly refreshed record (the 3.6.0 stated residual — now eliminated). A refresh
18
+ failure after a clean verify keeps the worktree and names the exact re-run command in both
19
+ lanes, preserving the original failure cause.
20
+ - **A TRACKED plans-chain path — the handoff or the seeded plan — refuses fail-closed in both
21
+ lanes.** Its drift is undeliverable (`land` categorically refuses docs/plans, and satellite
22
+ commits are not a lane), so tolerating it would strand the worktree. A fresh provision proves
23
+ both paths untracked at ONE captured commit — the same OID the branch is cut from — and
24
+ re-probes the new worktree's index and branch HEAD before the first write, catching
25
+ post-checkout hooks that force-add or even COMMIT them (a committed add leaves the tree
26
+ clean, invisible to the clean-tree verify). `--resume` probes the branch HEAD tree
27
+ unconditionally plus the live index before reading the handoff identity.
28
+ - **Recovery is surgical and honest.** An index-only entry names a pathspec-LITERAL
29
+ `git rm --cached` (a magic-shaped seed name never touches a pattern-matching neighbor) plus
30
+ the ignore-rule restoration when the worktree's live rules lost it; a branch-HEAD-tracked
31
+ file names salvage FIRST, then consented `cleanup --abandon` ONLY where the record identity
32
+ binds (a malformed record routes to the shipped record recovery — abandon is never promised);
33
+ the post-add hook STOP names inspect/salvage, then plain-git removal (never `cleanup
34
+ --abandon` — no handoff identity exists yet); an irregular entry (tree / symlink / gitlink)
35
+ fails closed with NO recovery command. Recovery never routes through the landing lane.
36
+ - **The mode doc states both contracts verbatim** (the corrected verify-then-refresh order and
37
+ the plans-chain refusal), each pinned by a named test. Resume tolerance is UNCHANGED this
38
+ release: the blanket clean-tree verify stays, pinned byte-exact.
39
+
40
+ ## 3.10.0 — install advice reads the worktree checkout (AD-071)
41
+
42
+ The install advice picked its package manager from MAIN's `package.json` and lockfiles while the
43
+ printed command targets the SATELLITE (`cd <worktree> && <manager> install`) — so a dirty MAIN
44
+ lockfile could push the advice into ambiguity or the wrong manager, and a MAIN advanced past the
45
+ satellite's base steered a satellite it no longer describes. The evidence and the target are now
46
+ the SAME tree:
47
+
48
+ - **Manager evidence reads the worktree's own live checkout** — the `packageManager` field and
49
+ the lockfile scan, the same live lane the dependency-free proof (3.6.0) and the node_modules
50
+ symlink probe already use. MAIN's mutable working tree never steers manager selection; MAIN
51
+ state legitimately steers only the symlink lane (the shared-cache link and its unlink-first
52
+ posture), unchanged.
53
+ - **The contract ships in the worktrees mode doc as one pinned sentence** (a named test locks
54
+ the exact wording): all manifest/lockfile install evidence is read from the worktree's own
55
+ live files at the moment the posture is resolved — deliberately NOT «exactly HEAD», because a
56
+ `post-checkout` hook can shape the checkout before the posture is resolved.
57
+ - **No resume-tolerance smuggling.** The shipped clean-tree `--resume` STOP is pinned byte-exact
58
+ by a real-git test; dirty-resume semantics remain a queued redesign. A satellite behind an
59
+ advanced MAIN now gets advice about ITSELF — pinned by a real-git test where MAIN's lockfile
60
+ advance does not re-steer the satellite's refreshed record.
61
+ - **Honest residual:** a manager signal living only in MAIN's working tree (e.g. an ignored,
62
+ uncommitted lockfile) no longer steers the advice — the checkout is the truth even when that
63
+ yields the npm default.
64
+
7
65
  ## 3.9.0 — the --include copy door proves what it copies (AD-070)
8
66
 
9
67
  Nothing bound the node the provision copy walk read to the node `--include` preflight approved:
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.9.0'
6
+ version: '3.11.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.9.0",
6
+ "version": "3.11.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.9.0",
3
+ "version": "3.11.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",
@@ -33,6 +33,11 @@ own verbatim error through the existing Git-error surface.
33
33
  is read from exactly one REQUIRED `## Provision record` section (a decoy field elsewhere cannot
34
34
  hijack identity); a missing or repeated section, or a duplicated single-valued field, is a typed
35
35
  STOP, never last-wins.
36
+ A TRACKED plans-chain path — the handoff or the seeded plan — refuses fail-closed in BOTH lanes (its drift is undeliverable at land, a manufactured dead end): a fresh provision proves both paths untracked at ONE captured commit before `git worktree add` and re-probes the new worktree's index and branch HEAD before any provision write; `--resume` probes the branch HEAD tree and the live index before reading the handoff identity; recovery is surgical (an index-only entry names a literal index removal — plus the ignore restoration where the rule was lost; a HEAD-tracked regular file names salvage first, then consented abandon only where identity binds) and never routes through land.
37
+ The provision record is refreshed LAST, after the post-provision verify, in both lanes — the
38
+ record attests only a verified provision, and a failed run leaves the prior record bytes: on a
39
+ first provision that failed after the stub write, the stub; a refusal BEFORE any provision
40
+ write (the tracked-plans-chain and probe-error STOPs) leaves no handoff at all.
36
41
  `--include` sources are identity-bound: preflight records each include root's identity (device,
37
42
  inode, and kind of the canonical node) BEFORE `git worktree add`, and a root that is neither a
38
43
  regular file nor a directory — or whose identity probe fails — is refused before any mutation.
@@ -104,8 +109,9 @@ PLUS the three facts a fresh satellite session cannot derive from its own checko
104
109
  isolated-install command when the package manager is unambiguous, the honest install-by-hand
105
110
  advice when it is not, and — when the provisioned `node_modules` is a SYMLINK into main — the
106
111
  unlink-first form, because a plain install through the symlink writes into MAIN and is never
107
- 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: exactly HEAD at provision time, the satellite's own committed state on `--resume`, never MAIN's mutable working tree) 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
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
108
113
  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.
109
115
 
110
116
  **Honesty:** there is NO preview step on the writers — over-warned by design. The tool never
111
117
  commits, never pushes, never runs a subscription CLI. Every content read and regular-file copy
@@ -1059,6 +1059,223 @@ const assertResumePlanCompatibility = ({ wtRoot, seedName, fs }) => {
1059
1059
  );
1060
1060
  };
1061
1061
 
1062
+ // ── D10: the tracked plans-chain refusal (slice R1 of the resume-verify design) ────────
1063
+ // A tracked handoff or seeded plan is a manufactured dead end: the landing lane
1064
+ // categorically refuses docs/plans (staged AND unstaged) and satellite commits are not a
1065
+ // lane, so drift on such a path can never be delivered. Both lanes refuse fail-closed
1066
+ // BEFORE any provision write. Probes are LITERAL by construction (D11): git lists the
1067
+ // docs/plans CONTAINER (that prefix carries no magic bytes) and each leaf is byte-compared
1068
+ // in code, so pathspec magic in a leaf name is inert.
1069
+
1070
+ const PLANS_CHAIN_KIND = Object.freeze({
1071
+ 100644: 'regular', 100755: 'regular', 120000: 'symlink', 160000: 'gitlink', '040000': 'tree',
1072
+ });
1073
+ const plansChainKind = (mode) => PLANS_CHAIN_KIND[mode] ?? `mode-${mode}`;
1074
+
1075
+ // `ls-tree -z`/`ls-files -z` lines: "<meta…>\t<path>" NUL-terminated → Map path → [entry].
1076
+ // A MULTIMAP on purpose: an unmerged index carries several stages of one path, and a
1077
+ // last-wins single value could hide an irregular stage behind a regular one. Each lister
1078
+ // maps its own meta shape (`ls-tree`: mode type oid · `ls-files --stage`: mode oid stage —
1079
+ // the STAGE is kept: a single stage-1/2/3 entry is unmerged even alone).
1080
+ const parseZPathEntries = (stdout, toEntry) => {
1081
+ const entries = new Map();
1082
+ for (const field of stdout.split('\0')) {
1083
+ const tab = field.indexOf('\t');
1084
+ if (tab === -1) continue;
1085
+ const rel = field.slice(tab + 1);
1086
+ if (!entries.has(rel)) entries.set(rel, []);
1087
+ entries.get(rel).push(toEntry(field.slice(0, tab).split(' ')));
1088
+ }
1089
+ return entries;
1090
+ };
1091
+
1092
+ // docs/plans entries of a tree-ish (an absent dir lists empty, exit 0).
1093
+ const listPlansTreeEntries = ({ git, cwd, treeish }) => {
1094
+ const r = git(['ls-tree', '-z', treeish, `${PLANS_REL}/`], cwd);
1095
+ if (r.status !== 0) return { error: (r.stderr || r.stdout).trim() || 'git ls-tree failed' };
1096
+ return { entries: parseZPathEntries(r.stdout, (meta) => ({ mode: meta[0], stage: '0' })) };
1097
+ };
1098
+
1099
+ const listPlansIndexEntries = ({ git, cwd }) => {
1100
+ const r = git(['ls-files', '--cached', '--stage', '-z', '--', PLANS_REL], cwd);
1101
+ if (r.status !== 0) return { error: (r.stderr || r.stdout).trim() || 'git ls-files failed' };
1102
+ return { entries: parseZPathEntries(r.stdout, (meta) => ({ mode: meta[0], stage: meta[2] ?? '0' })) };
1103
+ };
1104
+
1105
+ const plansChainRels = ({ slug, seedName }) =>
1106
+ [...new Set([`${PLANS_REL}/${handoffBasename(slug)}`, `${PLANS_REL}/${seedName}`])];
1107
+
1108
+ // The finding kind is decided over EVERY entry of the path from BOTH sources; an unmerged
1109
+ // index state — several stages OR a single non-zero stage — is irregular outright, never a
1110
+ // surgical-recovery candidate.
1111
+ const classifyPlansChainFinding = ({ headEntries = [], indexEntries = [] }) => {
1112
+ const kinds = [...headEntries, ...indexEntries].map(({ mode }) => plansChainKind(mode));
1113
+ const unmerged = indexEntries.length > 1 || indexEntries.some(({ stage }) => stage !== '0');
1114
+ const irregular = unmerged || kinds.some((k) => k !== 'regular');
1115
+ return { irregular, kind: unmerged ? 'unmerged-index' : (kinds.find((k) => k !== 'regular') ?? 'regular') };
1116
+ };
1117
+
1118
+ const literalIndexRemoval = (rel) => `git rm --cached -- ${shellQuoteArg(`:(literal)${rel}`)}`;
1119
+
1120
+ // FRESH, pre-mutation: the tree proof reads EXACTLY the captured commit — the same OID the
1121
+ // branch is cut from — so probe and mutation bind to one object.
1122
+ const assertPlansChainCleanAtCapturedOid = ({ git, root, oid, rels }) => {
1123
+ const tree = listPlansTreeEntries({ git, cwd: root, treeish: oid });
1124
+ if (tree.error) {
1125
+ throw stop(`provision: the plans-chain tree probe failed at the captured commit ${oid}: ${tree.error}`);
1126
+ }
1127
+ for (const rel of rels) {
1128
+ const headEntries = tree.entries.get(rel);
1129
+ if (headEntries === undefined) continue;
1130
+ const { irregular, kind } = classifyPlansChainFinding({ headEntries });
1131
+ if (irregular) {
1132
+ throw stop(`provision refuses: ${rel} is tracked as a ${kind} entry in the captured commit ${oid} — fail-closed; no worktree was created. ${PLANS_REL} must stay git-ignored and untracked.`);
1133
+ }
1134
+ // The reachable poisoned state usually has the removal ALREADY STAGED at main (with the
1135
+ // path still index-tracked the docs/plans entry gate refuses first) — the advice must
1136
+ // match the live index, else the printed command fails «pathspec did not match».
1137
+ const index = listPlansIndexEntries({ git, cwd: root });
1138
+ if (index.error) {
1139
+ throw stop(`provision: the plans-chain index probe failed at main: ${index.error}`);
1140
+ }
1141
+ const stillIndexed = index.entries.has(rel);
1142
+ throw stop([
1143
+ `provision refuses: ${rel} is tracked in the captured commit ${oid} — a tracked plans-chain path is undeliverable and would strand the worktree. No worktree was created.`,
1144
+ ...(stillIndexed
1145
+ ? ['Recovery (surgical, at main): untrack it and commit the removal —', ` ${literalIndexRemoval(rel)}`, 'then commit and re-run provision.']
1146
+ : ['Recovery (surgical, at main): the removal is already staged — commit it, then re-run provision.']),
1147
+ `${PLANS_REL} must stay git-ignored and untracked.`,
1148
+ ].join('\n'));
1149
+ }
1150
+ };
1151
+
1152
+ // FRESH, post-add, pre-write: a post-checkout hook may force-add or even COMMIT a
1153
+ // plans-chain path on the new branch (a committed add leaves the tree clean, invisible to
1154
+ // the blanket verify) — re-probe the live index AND the branch HEAD before the first write.
1155
+ const assertPlansChainCleanPostAdd = ({ git, wtRoot, branch, rels }) => {
1156
+ const head = listPlansTreeEntries({ git, cwd: wtRoot, treeish: 'HEAD' });
1157
+ if (head.error) throw stop(`provision: the post-add plans-chain probe failed in the new worktree: ${head.error}`);
1158
+ const index = listPlansIndexEntries({ git, cwd: wtRoot });
1159
+ if (index.error) throw stop(`provision: the post-add plans-chain probe failed in the new worktree: ${index.error}`);
1160
+ const offending = rels
1161
+ .filter((rel) => head.entries.has(rel) || index.entries.has(rel))
1162
+ .map((rel) => ({ rel, ...classifyPlansChainFinding({ headEntries: head.entries.get(rel), indexEntries: index.entries.get(rel) }) }));
1163
+ if (offending.length === 0) return;
1164
+ // The kind matrix holds in every lane: ANY irregular entry → fail-closed, no recovery
1165
+ // command at all. Both STOPs suppress the generic kept-worktree NOTE — its --resume advice
1166
+ // would steer away from (or contradict) the honest lane here.
1167
+ if (offending.some(({ irregular }) => irregular)) {
1168
+ throw stop([
1169
+ 'provision refuses: the new worktree already tracks irregular plans-chain entr(y/ies) before any provision write — fail-closed:',
1170
+ ...offending.map(({ rel, kind }) => ` ${rel} (${kind})`),
1171
+ 'Nothing was written into the worktree.',
1172
+ ].join('\n'), { suppressKeptNote: true });
1173
+ }
1174
+ throw stop([
1175
+ 'provision refuses: the new worktree already tracks plans-chain path(s) before any provision write — a post-checkout hook force-added or committed:',
1176
+ ...offending.map(({ rel }) => ` ${rel}`),
1177
+ 'A tracked plans-chain path is undeliverable and the worktree cannot converge; nothing was written into it.',
1178
+ 'Recovery, in order:',
1179
+ ' 1. inspect the worktree and copy OUT any hook-created content you value',
1180
+ ' 2. fix or remove the offending post-checkout hook',
1181
+ ' 3. with consent, remove the kept worktree from MAIN:',
1182
+ ` git worktree remove --force ${shellQuoteArg(wtRoot)}`,
1183
+ ` git branch -D ${shellQuoteArg(branch)}`,
1184
+ ' 4. re-run provision',
1185
+ ].join('\n'), { suppressKeptNote: true });
1186
+ };
1187
+
1188
+ // RESUME: the branch HEAD tree is probed UNCONDITIONALLY plus the live index, BEFORE the
1189
+ // handoff-identity reads — the probe result is collected first; the identity parse steers
1190
+ // only the recovery wording (a malformed record never cancels a tracked classification).
1191
+ const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels, fs }) => {
1192
+ const head = listPlansTreeEntries({ git, cwd: wtRoot, treeish: 'HEAD' });
1193
+ if (head.error) {
1194
+ throw stop(`--resume: the plans-chain probe failed (branch HEAD tree): ${head.error} — the prior record is untouched`);
1195
+ }
1196
+ const index = listPlansIndexEntries({ git, cwd: wtRoot });
1197
+ if (index.error) {
1198
+ throw stop(`--resume: the plans-chain probe failed (live index): ${index.error} — the prior record is untouched`);
1199
+ }
1200
+ const headTracked = [];
1201
+ const indexOnly = [];
1202
+ for (const rel of rels) {
1203
+ const headEntries = head.entries.get(rel);
1204
+ const indexEntries = index.entries.get(rel);
1205
+ if (headEntries !== undefined) headTracked.push({ rel, ...classifyPlansChainFinding({ headEntries, indexEntries }) });
1206
+ else if (indexEntries !== undefined) indexOnly.push({ rel, ...classifyPlansChainFinding({ indexEntries }) });
1207
+ }
1208
+ if (headTracked.length === 0 && indexOnly.length === 0) return;
1209
+
1210
+ // Best-effort identity read, for RECOVERY WORDING only: destructive removal is offered
1211
+ // ONLY where the record identity provably binds this worktree. The failure REASON is kept
1212
+ // and printed INLINE — advising a re-run would loop (this probe fires before the shipped
1213
+ // identity STOP can ever be reached).
1214
+ const identity = (() => {
1215
+ try {
1216
+ const rf = readFileNoFollow(fs, join(wtRoot, PLANS_REL, handoffBasename(slug)));
1217
+ if (!rf.bytes) return { binds: false, reason: 'the handoff is not readable as a regular file' };
1218
+ const record = parseProvisionRecord(String(rf.bytes));
1219
+ if (record.slug !== slug) return { binds: false, reason: `the record slug is ${record.slug ?? '(missing)'}, the live slug is ${slug}` };
1220
+ if (record.branch !== branch) return { binds: false, reason: `the record branch is ${record.branch ?? '(missing)'}, the live branch is ${branch}` };
1221
+ return { binds: true, reason: null };
1222
+ } catch (err) {
1223
+ return { binds: false, reason: errorText(err) };
1224
+ }
1225
+ })();
1226
+
1227
+ // The kind matrix governs the WHOLE set: any irregular finding (an irregular mode in
1228
+ // EITHER source, or a multistage/unmerged index entry) withholds EVERY recovery command —
1229
+ // destructive or surgical advice for a regular sibling would also cover the irregular
1230
+ // content the contract fails closed on.
1231
+ if ([...headTracked, ...indexOnly].some(({ irregular }) => irregular)) {
1232
+ throw stop([
1233
+ '--resume refuses: irregular plans-chain entr(y/ies) are tracked in this worktree — fail-closed, recovery commands withheld for the whole set:',
1234
+ ...headTracked.map(({ rel, kind }) => ` ${rel} (${kind}, branch HEAD)`),
1235
+ ...indexOnly.map(({ rel, kind }) => ` ${rel} (${kind}, index only)`),
1236
+ 'The prior provision record is untouched.',
1237
+ ].join('\n'));
1238
+ }
1239
+
1240
+ // A poisoned base dominates the scenario: consented abandon disposes the WHOLE worktree,
1241
+ // so per-path index-only surgical commands beside it would be contradictory noise — the
1242
+ // index-only paths are LISTED as findings and the one salvage-then-abandon lane covers all.
1243
+ if (headTracked.length > 0) {
1244
+ throw stop([
1245
+ ...headTracked.map(({ rel }) =>
1246
+ `--resume refuses: ${rel} is tracked in this worktree's branch HEAD — the legacy poisoned-base pathology; its drift is undeliverable, so this worktree cannot converge.`),
1247
+ ...indexOnly.map(({ rel }) =>
1248
+ `--resume refuses: ${rel} is staged in this worktree's index — undeliverable beside the poisoned base above.`),
1249
+ 'Recovery, in order:',
1250
+ ' 1. salvage FIRST: copy the live files (and anything else you value) OUT of the worktree — plain file copies, no identity needed',
1251
+ identity.binds
1252
+ ? ` 2. then, with consent: ${composeOwnToolPrefix(root)} ${composeCleanupCommand({ slug, branch, abandon: true })} (DESTROYS unlanded work)`
1253
+ : ` 2. the handoff record does not bind: ${identity.reason} — a consented destructive removal is NOT offered; repair the record before any identity-bound lane can run`,
1254
+ ].join('\n'));
1255
+ }
1256
+
1257
+ const lines = [];
1258
+ for (const { rel } of indexOnly) {
1259
+ // The rule probe reads the EXACT offending path, index-independently: `--no-index` skips
1260
+ // the index consultation that makes a plain check-ignore lie about tracked paths
1261
+ // (live-probed), and a fabricated sibling name could disagree with the real rule set.
1262
+ const rule = git(['check-ignore', '--no-index', '--', rel], wtRoot);
1263
+ if (rule.status !== 0 && rule.status !== 1) {
1264
+ throw stop(`--resume: the ignore-rule probe failed for ${rel}: ${(rule.stderr || rule.stdout).trim()} — the prior record is untouched`);
1265
+ }
1266
+ const ignoreRuleGone = rule.status === 1;
1267
+ lines.push(
1268
+ `--resume refuses: ${rel} is staged in this worktree's index (absent from the branch HEAD) — a session force-add whose staged entry can never be delivered.`,
1269
+ 'Recovery (surgical, in the worktree):',
1270
+ ` 1. ${literalIndexRemoval(rel)}`,
1271
+ ` (if git refuses — the staged content differs from the live file — salvage the staged blob first: git show ${shellQuoteArg(`:${rel}`)} > <a file OUTSIDE this worktree>, then repeat with -f; the live file stays untouched)`,
1272
+ ...(ignoreRuleGone ? [` 2. restore the git-ignore rule covering ${rel} (provision requires ${PLANS_REL} ignored)`] : []),
1273
+ ' then re-run --resume.',
1274
+ );
1275
+ }
1276
+ throw stop(lines.join('\n'));
1277
+ };
1278
+
1062
1279
  // ── the handoff artifact (the tool's own record inside it; list/cleanup read it) ───────
1063
1280
 
1064
1281
  // The orientation facts a fresh satellite session cannot derive from its own checkout. They are
@@ -1374,8 +1591,10 @@ const LOCKFILE_MANAGERS = Object.freeze([
1374
1591
  const NEUTRAL_INSTALL_ADVICE =
1375
1592
  'install command not printed — package manager is ambiguous or unknown; install dependencies in the worktree by hand';
1376
1593
 
1377
- const resolveInstallAdvice = ({ root, wtRoot, fs }) => {
1378
- const pkg = readFileNoFollow(fs, join(root, 'package.json'));
1594
+ // Advice evidence = the WORKTREE'S OWN LIVE checkout (manifest AND lockfiles): the printed
1595
+ // command runs in the satellite, so what main's working tree happens to hold must not steer it.
1596
+ const resolveInstallAdvice = ({ wtRoot, fs }) => {
1597
+ const pkg = readFileNoFollow(fs, join(wtRoot, 'package.json'));
1379
1598
  let manager = null;
1380
1599
  let inspectLocks = false;
1381
1600
  if (pkg.absent) {
@@ -1406,7 +1625,7 @@ const resolveInstallAdvice = ({ root, wtRoot, fs }) => {
1406
1625
  for (const [name, candidate] of LOCKFILE_MANAGERS) {
1407
1626
  let st;
1408
1627
  try {
1409
- st = fs.lstat(join(root, name));
1628
+ st = fs.lstat(join(wtRoot, name));
1410
1629
  } catch (err) {
1411
1630
  if (err?.code === 'ENOENT') continue;
1412
1631
  return { command: null, instruction: NEUTRAL_INSTALL_ADVICE };
@@ -1481,9 +1700,10 @@ const declaresNativeBuild = (fs, dir) => lstatNoFollow(fs.lstat, join(dir, 'bind
1481
1700
 
1482
1701
  // PROVABLY dependency-free, or nothing — read from the WORKTREE'S OWN LIVE CHECKOUT, never from
1483
1702
  // MAIN's mutable working tree: the evidence is what an install run in THIS worktree would actually
1484
- // read. At provision time that is exactly HEAD; on --resume it follows the session's own edits, in
1485
- // both directions (gained dependencies revoke the proof, shed ones grant it) the same live lane
1486
- // as the node_modules symlink probe. A dirty main manifest must neither grant nor revoke a verdict
1703
+ // read, taken at the moment the posture is resolved (a post-checkout hook may already have shaped
1704
+ // the checkout; on --resume it follows the session's own edits, in both directionsgained
1705
+ // dependencies revoke the proof, shed ones grant it the same live lane
1706
+ // as the node_modules symlink probe). A dirty main manifest must neither grant nor revoke a verdict
1487
1707
  // about content it does not describe. A `workspaces` field of ANY shape
1488
1708
  // is UNKNOWN outright — a workspace install materializes member links and `.bin` shims even with
1489
1709
  // zero dependencies, so a workspace tree is never provably install-free. Everything else the tool
@@ -1512,20 +1732,20 @@ const declaresNoDependencies = ({ wtRoot, fs }) => {
1512
1732
  // an earlier provision left — an install through it writes into MAIN, and the posture must never
1513
1733
  // hide that). Only then may a PROVEN dependency-free checkout short-circuit: a verdict of
1514
1734
  // "nothing to install" must not ride an install instruction.
1515
- const resolveInstallPosture = ({ root, wtRoot, dependencyFree, fs }) => {
1735
+ const resolveInstallPosture = ({ wtRoot, dependencyFree, fs }) => {
1516
1736
  const nmPath = join(wtRoot, 'node_modules');
1517
1737
  const nm = lstatNoFollow(fs.lstat, nmPath);
1518
1738
  if (nm !== null && nm.isSymbolicLink()) {
1519
- const advice = resolveInstallAdvice({ root, wtRoot, fs });
1739
+ const advice = resolveInstallAdvice({ wtRoot, fs });
1520
1740
  const separator = advice.command === null ? ' — ' : ' && ';
1521
1741
  return `the provisioned node_modules is a symlink into MAIN (an install through it writes into MAIN) — for isolation remove it first: rm ${shellQuoteArg(nmPath)}${separator}${advice.instruction}`;
1522
1742
  }
1523
1743
  if (dependencyFree) return NO_DEPENDENCIES_POSTURE;
1524
- return resolveInstallAdvice({ root, wtRoot, fs }).instruction;
1744
+ return resolveInstallAdvice({ wtRoot, fs }).instruction;
1525
1745
  };
1526
1746
 
1527
1747
  const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report }) => {
1528
- const install = resolveInstallAdvice({ root, wtRoot, fs });
1748
+ const install = resolveInstallAdvice({ wtRoot, fs });
1529
1749
  if (installFlag) {
1530
1750
  const dst = join(wtRoot, 'node_modules');
1531
1751
  const existing = lstatNoFollow(fs.lstat, dst);
@@ -1806,13 +2026,22 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1806
2026
  }
1807
2027
  const wtCommon = gitLine(git, ['rev-parse', '--path-format=absolute', '--git-common-dir'], targetReal);
1808
2028
  if (wtCommon !== commonDir) throw stop(`--resume identity mismatch: ${targetReal} does not share this repo's git dir`);
2029
+ assertPlansChainCleanOnResume({ git, root, wtRoot: targetReal, slug, branch, rels: plansChainRels({ slug, seedName: seed.name }), fs });
1809
2030
  assertResumeHandoffIdentity({ wtRoot: targetReal, slug, branch, fs });
1810
2031
  assertResumePlanCompatibility({ wtRoot: targetReal, seedName: seed.name, fs });
1811
2032
  runWritabilityProbe();
1812
2033
  report.push(`resuming provision at ${targetReal} (branch ${branch})`);
1813
2034
  } else {
1814
2035
  runWritabilityProbe();
1815
- const add = git(['worktree', 'add', '-b', branch, targetReal], root);
2036
+ // ONE captured commit binds the plans-chain tree proof AND the branch cut: a clean
2037
+ // captured OID with a subsequently-moved HEAD still cuts from the captured OID.
2038
+ const capture = git(['rev-parse', 'HEAD'], root);
2039
+ if (capture.status !== 0) {
2040
+ throw stop(`provision: cannot capture the base commit (git rev-parse HEAD failed in the main repo): ${(capture.stderr || capture.stdout).trim()} — the plans-chain proof and the branch cut must bind to one commit`);
2041
+ }
2042
+ const capturedOid = capture.stdout.replace(/\r?\n$/, '');
2043
+ assertPlansChainCleanAtCapturedOid({ git, root, oid: capturedOid, rels: plansChainRels({ slug, seedName: seed.name }) });
2044
+ const add = git(['worktree', 'add', '-b', branch, targetReal, capturedOid], root);
1816
2045
  if (add.status !== 0) {
1817
2046
  throw stop(
1818
2047
  [
@@ -1827,9 +2056,12 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1827
2056
  // any failure past this point leaves a real created worktree — the error must say so and
1828
2057
  // hand back the exact finish command, never just the local cause
1829
2058
  try {
2059
+ if (!flags.resume) {
2060
+ assertPlansChainCleanPostAdd({ git, wtRoot: targetReal, branch, rels: plansChainRels({ slug, seedName: seed.name }) });
2061
+ }
1830
2062
  return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log });
1831
2063
  } catch (err) {
1832
- if (!flags.resume && err?.message) {
2064
+ if (!flags.resume && err?.message && !err.suppressKeptNote) {
1833
2065
  err.message += `\nNOTE: the worktree at ${targetReal} (branch ${branch}) was created and KEPT — finish with: ${composeProvisionArgv({ root, slug, flags: { ...flags, resume: true } })} (or reclaim it with the consented cleanup).`;
1834
2066
  }
1835
2067
  throw err;
@@ -1856,23 +2088,6 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
1856
2088
 
1857
2089
  rebasePins({ root, wtRoot: targetPath, git, fs, report });
1858
2090
 
1859
- writeHandoffRecord({
1860
- wtRoot: targetPath,
1861
- slug,
1862
- branch,
1863
- fields: {
1864
- slug,
1865
- branch,
1866
- includes: includesRecorded,
1867
- nodeModules: nodeModulesMode,
1868
- vscode: vscodeMode,
1869
- install: resolveInstallPosture({ root, wtRoot: targetPath, dependencyFree, fs }),
1870
- ...orientationFields({ root, slug }),
1871
- },
1872
- fs,
1873
- report,
1874
- });
1875
-
1876
2091
  const inFlight = plansInFlight(targetPath, fs.readdir);
1877
2092
  if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
1878
2093
  throw stop(
@@ -1888,6 +2103,35 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
1888
2103
  );
1889
2104
  }
1890
2105
 
2106
+ // The record refresh runs LAST, after the in-flight check and the verify, in BOTH lanes —
2107
+ // the record attests only a VERIFIED provision; a failed run leaves the prior record bytes
2108
+ // (the stub on a failed first provision). On resume the generic kept-worktree NOTE does not
2109
+ // fire, so a refresh failure here wraps its own: the verify PASSED and the exact re-run
2110
+ // command is the honest recovery, with the original cause preserved.
2111
+ try {
2112
+ writeHandoffRecord({
2113
+ wtRoot: targetPath,
2114
+ slug,
2115
+ branch,
2116
+ fields: {
2117
+ slug,
2118
+ branch,
2119
+ includes: includesRecorded,
2120
+ nodeModules: nodeModulesMode,
2121
+ vscode: vscodeMode,
2122
+ install: resolveInstallPosture({ wtRoot: targetPath, dependencyFree, fs }),
2123
+ ...orientationFields({ root, slug }),
2124
+ },
2125
+ fs,
2126
+ report,
2127
+ });
2128
+ } catch (err) {
2129
+ if (flags.resume && err?.message) {
2130
+ err.message += `\nNOTE: the worktree at ${targetPath} (branch ${branch}) is KEPT — the provision verify PASSED and only the record refresh failed; finish with: ${composeProvisionArgv({ root, slug, flags })} (the prior record bytes are untouched).`;
2131
+ }
2132
+ throw err;
2133
+ }
2134
+
1891
2135
  const base = gitLine(git, ['rev-parse', 'HEAD'], targetPath) ?? '(unknown)';
1892
2136
  for (const line of report) log(line);
1893
2137
  log(`[worktrees] provisioned ${slug} at ${targetPath} (branch ${branch}, base ${base})`);