@sabaiway/agent-workflow-kit 3.10.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,39 @@ 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
+
7
40
  ## 3.10.0 — install advice reads the worktree checkout (AD-071)
8
41
 
9
42
  The install advice picked its package manager from MAIN's `package.json` and lockfiles while 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.10.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.10.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.10.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,7 +109,7 @@ 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: 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, after the record refresh) 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.
109
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.
110
115
 
@@ -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
@@ -1809,13 +2026,22 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1809
2026
  }
1810
2027
  const wtCommon = gitLine(git, ['rev-parse', '--path-format=absolute', '--git-common-dir'], targetReal);
1811
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 });
1812
2030
  assertResumeHandoffIdentity({ wtRoot: targetReal, slug, branch, fs });
1813
2031
  assertResumePlanCompatibility({ wtRoot: targetReal, seedName: seed.name, fs });
1814
2032
  runWritabilityProbe();
1815
2033
  report.push(`resuming provision at ${targetReal} (branch ${branch})`);
1816
2034
  } else {
1817
2035
  runWritabilityProbe();
1818
- 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);
1819
2045
  if (add.status !== 0) {
1820
2046
  throw stop(
1821
2047
  [
@@ -1830,9 +2056,12 @@ export const runProvision = ({ argvSlug, flags, cwd, git, deps, log }) => {
1830
2056
  // any failure past this point leaves a real created worktree — the error must say so and
1831
2057
  // hand back the exact finish command, never just the local cause
1832
2058
  try {
2059
+ if (!flags.resume) {
2060
+ assertPlansChainCleanPostAdd({ git, wtRoot: targetReal, branch, rels: plansChainRels({ slug, seedName: seed.name }) });
2061
+ }
1833
2062
  return finishProvision({ root, rootReal, targetPath: targetReal, slug, branch, flags, seed, includeSources, provisionSet, git, deps, fs, report, log });
1834
2063
  } catch (err) {
1835
- if (!flags.resume && err?.message) {
2064
+ if (!flags.resume && err?.message && !err.suppressKeptNote) {
1836
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).`;
1837
2066
  }
1838
2067
  throw err;
@@ -1859,23 +2088,6 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
1859
2088
 
1860
2089
  rebasePins({ root, wtRoot: targetPath, git, fs, report });
1861
2090
 
1862
- writeHandoffRecord({
1863
- wtRoot: targetPath,
1864
- slug,
1865
- branch,
1866
- fields: {
1867
- slug,
1868
- branch,
1869
- includes: includesRecorded,
1870
- nodeModules: nodeModulesMode,
1871
- vscode: vscodeMode,
1872
- install: resolveInstallPosture({ wtRoot: targetPath, dependencyFree, fs }),
1873
- ...orientationFields({ root, slug }),
1874
- },
1875
- fs,
1876
- report,
1877
- });
1878
-
1879
2091
  const inFlight = plansInFlight(targetPath, fs.readdir);
1880
2092
  if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
1881
2093
  throw stop(
@@ -1891,6 +2103,35 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
1891
2103
  );
1892
2104
  }
1893
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
+
1894
2135
  const base = gitLine(git, ['rev-parse', 'HEAD'], targetPath) ?? '(unknown)';
1895
2136
  for (const line of report) log(line);
1896
2137
  log(`[worktrees] provisioned ${slug} at ${targetPath} (branch ${branch}, base ${base})`);