@sabaiway/agent-workflow-kit 5.10.0 → 5.11.1

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +1 -1
  4. package/capability.json +1 -1
  5. package/package.json +1 -1
  6. package/references/hooks/gate-approve.mjs +13 -2
  7. package/references/hooks/state-block-guard.mjs +14 -2
  8. package/references/modes/commit-guard.md +11 -8
  9. package/references/modes/core-evidence.md +1 -1
  10. package/references/modes/dispatch.md +32 -10
  11. package/references/modes/worktrees.md +47 -3
  12. package/references/scripts/archive-changelog.mjs +14 -3
  13. package/references/scripts/archive-decisions.mjs +14 -3
  14. package/references/scripts/archive-issues.mjs +14 -3
  15. package/references/scripts/check-docs-size.mjs +14 -3
  16. package/references/scripts/migrate-gates.mjs +13 -2
  17. package/tools/ack-write.mjs +3 -3
  18. package/tools/advisor-matrix.mjs +165 -0
  19. package/tools/autonomy-doctor.mjs +2 -3
  20. package/tools/bridge-settings.mjs +2 -3
  21. package/tools/cheap-agents.mjs +3 -3
  22. package/tools/commands.mjs +4 -5
  23. package/tools/commit-guard.mjs +77 -20
  24. package/tools/core-evidence.mjs +12 -3
  25. package/tools/coverage-check.mjs +2 -3
  26. package/tools/delegation.mjs +2 -3
  27. package/tools/detect-backends.mjs +2 -3
  28. package/tools/dispatch-advisor.mjs +323 -0
  29. package/tools/dispatch.mjs +174 -109
  30. package/tools/doc-parity.mjs +69 -16
  31. package/tools/family-registry.mjs +3 -3
  32. package/tools/flow-adoption-mint.mjs +70 -0
  33. package/tools/flow-append.mjs +309 -0
  34. package/tools/flow-chain-state.mjs +91 -0
  35. package/tools/flow-check-cores.mjs +35 -6
  36. package/tools/flow-check-rungs.mjs +20 -2
  37. package/tools/flow-check.mjs +22 -8
  38. package/tools/flow-delta-proof.mjs +307 -0
  39. package/tools/flow-record.mjs +1 -1
  40. package/tools/flow-store-read.mjs +3 -3
  41. package/tools/flow-store.mjs +35 -812
  42. package/tools/flow-subset-budget.mjs +81 -0
  43. package/tools/flow-writer.mjs +3 -3
  44. package/tools/gate-hook.mjs +3 -3
  45. package/tools/gates-init.mjs +3 -3
  46. package/tools/grounding.mjs +2 -3
  47. package/tools/hide-footprint.mjs +2 -3
  48. package/tools/inject-methodology.mjs +2 -3
  49. package/tools/lens-region.mjs +2 -3
  50. package/tools/manifest/validate.mjs +2 -3
  51. package/tools/migrate-adr-store.mjs +3 -3
  52. package/tools/observation-builder.mjs +123 -0
  53. package/tools/path-inventory.mjs +2 -3
  54. package/tools/procedures.mjs +3 -3
  55. package/tools/receipt-deadline.mjs +2 -3
  56. package/tools/recipes.mjs +2 -3
  57. package/tools/recommendations.mjs +3 -3
  58. package/tools/release-scan.mjs +2 -3
  59. package/tools/repo-search.mjs +2 -3
  60. package/tools/review-state.mjs +3 -3
  61. package/tools/run-gates.mjs +2 -3
  62. package/tools/sandbox-masks.mjs +3 -3
  63. package/tools/satellite-locator.mjs +179 -0
  64. package/tools/set-autonomy.mjs +2 -3
  65. package/tools/set-flow.mjs +3 -3
  66. package/tools/set-recipe.mjs +2 -3
  67. package/tools/setup-backends.mjs +3 -3
  68. package/tools/store-append.mjs +2 -2
  69. package/tools/uninstall.mjs +2 -3
  70. package/tools/velocity-profile.mjs +3 -3
  71. package/tools/worktree-handoff-return.mjs +369 -0
  72. package/tools/worktree-prompt.mjs +190 -0
  73. package/tools/worktrees-record.mjs +171 -0
  74. package/tools/worktrees.mjs +311 -300
@@ -13,31 +13,47 @@ import {
13
13
  closeSync, constants as fsC,
14
14
  } from 'node:fs';
15
15
  import { join, dirname, basename, resolve, relative, isAbsolute, sep } from 'node:path';
16
- import { pathToFileURL, fileURLToPath } from 'node:url';
16
+ import { fileURLToPath } from 'node:url';
17
17
  import { spawnSync } from 'node:child_process';
18
18
  import { randomBytes } from 'node:crypto';
19
19
  import {
20
20
  KIT_OWN_PATHS, KNOWN_FOOTPRINT, expandGlob, normalizeSlashes, isDirPattern, isGlobPattern,
21
21
  patternToProbe,
22
22
  } from './known-footprint.mjs';
23
+ import { isDirectRun } from './direct-run.mjs';
23
24
  import { isScratchPlanName, plansInFlight, PLANS_REL, shellQuoteArg } from './review-state.mjs';
24
25
  import { writeContainedFileAtomic } from './atomic-write.mjs';
25
26
  import { assertContainedRealPath } from './fs-safe.mjs';
26
27
  import { isFinalCapableDeclaration } from './run-gates.mjs';
28
+ import {
29
+ WORKTREES_STOP, stop, EXIT, handoffBasename, recordValue, hasControlByte, displayValue,
30
+ composeProvisionRecordSection, composeLandingValue, composeHandoffStub,
31
+ locateProvisionRecordSection, parseProvisionRecord,
32
+ } from './worktrees-record.mjs';
33
+ import {
34
+ DEFAULT_BRANCH_PREFIX, listWorktrees, classifyNodeNoFollow, scanPlansDir, findSatelliteEntry,
35
+ readSatelliteIdentity,
36
+ } from './satellite-locator.mjs';
37
+ import { composeSatellitePrompt, resolveSeededPlan } from './worktree-prompt.mjs';
38
+
39
+ // The record format and the satellite locator now live in leaves the dispatch side reads too — but
40
+ // every name they took away is re-exported here, so no import site and no asserted error `code`
41
+ // moved when they left.
42
+ export {
43
+ WORKTREES_STOP, stop, EXIT, handoffBasename, QUEUE_SHARED_RULE, composeHandoffStub,
44
+ parseProvisionRecord,
45
+ } from './worktrees-record.mjs';
46
+ export {
47
+ DEFAULT_BRANCH_PREFIX, parseWorktreeList, findSatelliteEntry, readSatelliteIdentity,
48
+ } from './satellite-locator.mjs';
27
49
 
28
- export const WORKTREES_STOP = 'WORKTREES_STOP';
29
- export const stop = (message, fields = {}) =>
30
- Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'WorktreesStop', code: WORKTREES_STOP, ...fields });
31
50
  const usageStop = (message) => stop(message, { exitCode: EXIT.usage });
32
51
  const errorText = (error) => String(error?.message ?? error).replace(/^\[agent-workflow-kit\] /, '');
33
52
  const composeFailure = (primary, secondaryName, secondary) =>
34
53
  stop(`${errorText(primary)}; ${secondaryName} failed: ${errorText(secondary)}`);
35
54
 
36
- export const EXIT = Object.freeze({ ok: 0, stop: 1, usage: 2 });
37
55
  export const CONFIG_REL = 'docs/ai/worktrees.json';
38
56
  export const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
39
- export const DEFAULT_BRANCH_PREFIX = 'aw/';
40
- export const handoffBasename = (slug) => `handoff-${slug}.md`;
41
57
  const WORKTREES_TOOL_ABS = fileURLToPath(import.meta.url);
42
58
  const WORKTREES_TOOL_DIR = dirname(WORKTREES_TOOL_ABS);
43
59
 
@@ -67,6 +83,10 @@ const USAGE = [
67
83
  ` the ${CONFIG_REL} "parentDir" setting when present). --install only PRINTS the`,
68
84
  ' install command. --resume completes a half-done provision (identity-checked).',
69
85
  ' list show every worktree of this repo: slug, path, branch, base OID, dirty, handoff.',
86
+ ' prompt <slug>',
87
+ ' re-print the satellite cold-start prompt for a provisioned worktree: where it is,',
88
+ ' what MAIN answers now (derived LIVE, a stale record value is named), the handoff as',
89
+ ' the one return channel, and the bars. Read-only — it writes nothing.',
70
90
  ' land <slug> --prepare',
71
91
  ' stage the satellite diff onto a clean main (no commit — the commit stays a',
72
92
  ' dialogue ask). Refuses divergence, incomplete satellite state, or a dirty main.',
@@ -74,13 +94,21 @@ const USAGE = [
74
94
  ' remove a LANDED worktree (fail-closed verification); --abandon is the one',
75
95
  ' destructive arm and destroys unlanded work.',
76
96
  '',
77
- 'The slug is REQUIRED and positional on provision/land/cleanup: lowercase letters, digits,',
97
+ 'The slug is REQUIRED and positional on provision/prompt/land/cleanup: lowercase letters, digits,',
78
98
  'hyphens, max 64 chars, letter/digit first. Exit codes: 0 ok / 1 refusal / 2 usage.',
79
99
  ].join('\n');
80
100
 
81
101
  // ── deps + git plumbing (every seam injectable for hermetic tests) ─────────────────────
82
102
 
83
- const fsOf = (deps) => ({
103
+ const fsOf = (deps) => {
104
+ const fs = fsSeams(deps);
105
+ // The ONE content read, bound to THIS seam set and handed to the locator leaf, which owns no read
106
+ // door of its own — one body, one place, provable by the tripwires below it.
107
+ fs.readFileNoFollow = (abs) => readFileNoFollow(fs, abs);
108
+ return fs;
109
+ };
110
+
111
+ const fsSeams = (deps) => ({
84
112
  lstat: deps.lstat ?? lstatSync,
85
113
  mkdir: deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true })),
86
114
  mkdirPlain: deps.mkdirPlain ?? mkdirSync,
@@ -133,43 +161,9 @@ const lstatNoFollow = (lstat, path) => {
133
161
  }
134
162
  };
135
163
 
136
- const classifyNodeNoFollow = (path, fs) => {
137
- const node = (() => {
138
- try {
139
- return { stat: fs.lstat(path) };
140
- } catch (error) {
141
- return error?.code === 'ENOENT'
142
- ? { stat: null }
143
- : { error: error?.code ?? 'fs error' };
144
- }
145
- })();
146
- if (node.error) return { kind: 'error', error: node.error };
147
- if (node.stat === null) return { kind: 'absent' };
148
- if (!node.stat.isSymbolicLink()) {
149
- if (node.stat.isDirectory()) return { kind: 'plain-directory', stat: node.stat };
150
- if (node.stat.isFile()) return { kind: 'regular-file', stat: node.stat };
151
- return { kind: 'special', stat: node.stat };
152
- }
153
- const realPath = (() => {
154
- try {
155
- return { path: fs.realpath(path) };
156
- } catch (error) {
157
- return { error: error?.code ?? 'fs error' };
158
- }
159
- })();
160
- if (realPath.error) return { kind: 'symlink-unresolvable', error: realPath.error };
161
- const target = (() => {
162
- try {
163
- return { stat: fs.lstat(realPath.path) };
164
- } catch (error) {
165
- return { error: error?.code ?? 'fs error' };
166
- }
167
- })();
168
- if (target.error) return { kind: 'symlink-unresolvable', error: target.error };
169
- if (target.stat.isDirectory()) return { kind: 'symlink-to-directory', realPath: realPath.path, stat: node.stat };
170
- if (target.stat.isFile()) return { kind: 'symlink-to-file', realPath: realPath.path, stat: node.stat };
171
- return { kind: 'symlink-to-special', realPath: realPath.path, stat: node.stat };
172
- };
164
+ // classifyNodeNoFollow moved to satellite-locator.mjs; the ONE content-read door stays HERE, and the
165
+ // locator receives it through its injected fs seam — a second body anywhere is exactly what the
166
+ // door tripwires exist to prevent.
173
167
 
174
168
  // The ONE content-read door: no-follow lstat, then an O_NOFOLLOW|O_NONBLOCK descriptor with an
175
169
  // fstat recheck — a node swapped after the lstat can neither follow a link nor block on a FIFO.
@@ -541,36 +535,8 @@ export const realpathThroughExistingParent = (target, deps = {}) => {
541
535
 
542
536
  // ── roots + worktree registry ──────────────────────────────────────────────────────────
543
537
 
544
- export const parseWorktreeList = (text) => {
545
- const entries = [];
546
- let fields = [];
547
- const finishEntry = () => {
548
- if (fields.length === 0) return;
549
- const entry = { path: null, head: null, branch: null, detached: false, prunable: false, bare: false };
550
- for (const field of fields) {
551
- if (field.startsWith('worktree ')) entry.path = field.slice('worktree '.length);
552
- else if (field.startsWith('HEAD ')) entry.head = field.slice('HEAD '.length);
553
- else if (field.startsWith('branch ')) entry.branch = field.slice('branch '.length);
554
- else if (field === 'detached') entry.detached = true;
555
- else if (field === 'bare') entry.bare = true;
556
- else if (field === 'prunable' || field.startsWith('prunable ')) entry.prunable = true;
557
- }
558
- if (entry.path !== null) entries.push(entry);
559
- fields = [];
560
- };
561
- for (const field of String(text).split('\0')) {
562
- if (field === '') finishEntry();
563
- else fields.push(field);
564
- }
565
- finishEntry();
566
- return entries;
567
- };
568
-
569
- const listWorktrees = (git, cwd) => {
570
- const r = git(['worktree', 'list', '--porcelain', '-z'], cwd);
571
- if (r.status !== 0) throw stop(`git worktree list failed: ${r.stderr.trim() || r.stdout.trim()}`);
572
- return parseWorktreeList(r.stdout);
573
- };
538
+ // parseWorktreeList and listWorktrees moved to satellite-locator.mjs with the resolver that needs
539
+ // them; both are re-exported above, so every caller and test import site is unchanged.
574
540
 
575
541
  // The MAIN worktree is the first `git worktree list --porcelain -z` entry; provision/land/cleanup
576
542
  // refuse to run from inside a linked worktree.
@@ -994,33 +960,8 @@ const assertTargetOutsideSources = ({ targetReal, sources }) => {
994
960
 
995
961
  // ── the shared plans-chain scanner (resume identity + list ride the SAME no-follow walk) ─
996
962
 
997
- // Whole-chain no-follow: the worktree root, docs, and docs/plans must be plain directories;
998
- // handoff candidates count ONLY as regular files. states: ok | absent | unreadable.
999
- // ANY stat failure (not just readdir) renders honestly — list must never crash on a bad node.
1000
- const scanPlansDir = ({ wtRoot, fs }) => {
1001
- if (classifyNodeNoFollow(wtRoot, fs).kind !== 'plain-directory') return { state: 'unreadable' };
1002
- const docs = classifyNodeNoFollow(join(wtRoot, 'docs'), fs);
1003
- if (docs.kind === 'absent') return { state: 'absent' };
1004
- if (docs.kind !== 'plain-directory') return { state: 'unreadable' };
1005
- const plans = classifyNodeNoFollow(join(wtRoot, PLANS_REL), fs);
1006
- if (plans.kind === 'absent') return { state: 'absent' };
1007
- if (plans.kind !== 'plain-directory') return { state: 'unreadable' };
1008
- let names;
1009
- try {
1010
- names = fs.readdir(join(wtRoot, PLANS_REL));
1011
- } catch {
1012
- return { state: 'unreadable' };
1013
- }
1014
- const handoffs = [];
1015
- const nonRegular = [];
1016
- for (const n of names) {
1017
- if (!/^handoff-.+\.md$/.test(n)) continue;
1018
- const cand = classifyNodeNoFollow(join(wtRoot, PLANS_REL, n), fs);
1019
- if (cand.kind !== 'regular-file') nonRegular.push(n);
1020
- else handoffs.push(n);
1021
- }
1022
- return { state: 'ok', handoffs, nonRegular };
1023
- };
963
+ // scanPlansDir moved to satellite-locator.mjs the resolver is its heaviest caller, and the
964
+ // dispatch side needs the same walk to find a satellite without importing this tool.
1024
965
 
1025
966
  // Resume writes NOTHING before this: the existing handoff must be the live identity.
1026
967
  const assertResumeHandoffIdentity = ({ wtRoot, slug, branch, fs }) => {
@@ -1030,24 +971,24 @@ const assertResumeHandoffIdentity = ({ wtRoot, slug, branch, fs }) => {
1030
971
  }
1031
972
  if (scan.state === 'absent') return;
1032
973
  if (scan.nonRegular.length > 0) {
1033
- throw stop(`--resume: handoff-named entr${scan.nonRegular.length === 1 ? 'y is' : 'ies are'} not regular file(s): ${scan.nonRegular.join(', ')} — fix before resuming`);
974
+ throw stop(`--resume: handoff-named entr${scan.nonRegular.length === 1 ? 'y is' : 'ies are'} not regular file(s): ${scan.nonRegular.map(displayValue).join(', ')} — fix before resuming`);
1034
975
  }
1035
976
  if (scan.handoffs.length === 0) return;
1036
977
  if (scan.handoffs.length > 1) {
1037
- throw stop(`--resume: multiple handoff files found (${scan.handoffs.join(', ')}) — exactly one may exist`);
978
+ throw stop(`--resume: multiple handoff files found (${scan.handoffs.map(displayValue).join(', ')}) — exactly one may exist`);
1038
979
  }
1039
980
  const name = scan.handoffs[0];
1040
981
  if (name !== handoffBasename(slug)) {
1041
- throw stop(`--resume identity mismatch: the existing handoff is ${name}, the live slug is ${slug} (${handoffBasename(slug)})`);
982
+ throw stop(`--resume identity mismatch: the existing handoff is ${displayValue(name)}, the live slug is ${slug} (${handoffBasename(slug)})`);
1042
983
  }
1043
984
  const rf = readFileNoFollow(fs, join(wtRoot, PLANS_REL, name));
1044
- if (!rf.bytes) throw stop(`--resume: the handoff ${name} is not readable as a regular file — fix it before resuming`);
985
+ if (!rf.bytes) throw stop(`--resume: the handoff ${displayValue(name)} is not readable as a regular file — fix it before resuming`);
1045
986
  const record = parseProvisionRecord(String(rf.bytes));
1046
987
  if (record.slug !== slug) {
1047
- throw stop(`--resume identity mismatch: the handoff record slug is ${record.slug ?? '(missing)'}, the live slug is ${slug}`);
988
+ throw stop(`--resume identity mismatch: the handoff record slug is ${record.slug === null ? '(missing)' : displayValue(record.slug)}, the live slug is ${slug}`);
1048
989
  }
1049
990
  if (record.branch !== branch) {
1050
- throw stop(`--resume identity mismatch: the handoff record branch is ${record.branch ?? '(missing)'}, the live branch is ${branch}`);
991
+ throw stop(`--resume identity mismatch: the handoff record branch is ${record.branch === null ? '(missing)' : displayValue(record.branch)}, the live branch is ${branch}`);
1051
992
  }
1052
993
  };
1053
994
 
@@ -1056,12 +997,12 @@ const assertResumePlanCompatibility = ({ wtRoot, seedName, fs }) => {
1056
997
  if (inFlight.length === 0 || (inFlight.length === 1 && inFlight[0] === seedName)) return;
1057
998
  if (inFlight.length === 1) {
1058
999
  throw stop(
1059
- `--resume plan mismatch: found [${inFlight[0]}], expected [${seedName}] or no in-flight plan — ` +
1060
- `re-run with --as ${inFlight[0]}, or remove the existing plan by hand`,
1000
+ `--resume plan mismatch: found [${displayValue(inFlight[0])}], expected [${displayValue(seedName)}] or no in-flight plan — ` +
1001
+ `re-run with --as ${displayValue(inFlight[0])}, or remove the existing plan by hand`,
1061
1002
  );
1062
1003
  }
1063
1004
  throw stop(
1064
- `the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.join(', ')}] — remove the extras (or re-seed) and re-run --resume`,
1005
+ `the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.map(displayValue).join(', ')}] — remove the extras (or re-seed) and re-run --resume`,
1065
1006
  );
1066
1007
  };
1067
1008
 
@@ -1222,8 +1163,8 @@ const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels,
1222
1163
  const rf = readFileNoFollow(fs, join(wtRoot, PLANS_REL, handoffBasename(slug)));
1223
1164
  if (!rf.bytes) return { binds: false, reason: 'the handoff is not readable as a regular file' };
1224
1165
  const record = parseProvisionRecord(String(rf.bytes));
1225
- if (record.slug !== slug) return { binds: false, reason: `the record slug is ${record.slug ?? '(missing)'}, the live slug is ${slug}` };
1226
- if (record.branch !== branch) return { binds: false, reason: `the record branch is ${record.branch ?? '(missing)'}, the live branch is ${branch}` };
1166
+ if (record.slug !== slug) return { binds: false, reason: `the record slug is ${record.slug === null ? '(missing)' : displayValue(record.slug)}, the live slug is ${slug}` };
1167
+ if (record.branch !== branch) return { binds: false, reason: `the record branch is ${record.branch === null ? '(missing)' : displayValue(record.branch)}, the live branch is ${branch}` };
1227
1168
  return { binds: true, reason: null };
1228
1169
  } catch (err) {
1229
1170
  return { binds: false, reason: errorText(err) };
@@ -1287,8 +1228,8 @@ const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels,
1287
1228
  // The orientation facts a fresh satellite session cannot derive from its own checkout. They are
1288
1229
  // CONSTANTS so the doc-parity registry can pin the mode doc to the exact strings the tool emits.
1289
1230
  export const QUEUE_BASENAME = 'queue.md';
1290
- export const QUEUE_SHARED_RULE =
1291
- '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';
1231
+ // QUEUE_SHARED_RULE travels with the composer that emits it (worktrees-record.mjs) and is
1232
+ // re-exported above; LANDING_FROM_MAIN stays where its only user is.
1292
1233
  export const LANDING_FROM_MAIN = 'landing runs FROM MAIN, never from this worktree';
1293
1234
  export const NO_DEPENDENCIES_POSTURE = 'no install needed — the project declares no dependencies';
1294
1235
  // The recorded node_modules mode for that same verdict: provision neither advised nor created a
@@ -1431,108 +1372,16 @@ const verifyPlacedPaths = ({ git, wtRoot, members }) => {
1431
1372
  if (failures.length > 0) throw stop(composeOwnedVerifyStop(failures));
1432
1373
  };
1433
1374
 
1434
- // The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
1435
- // is refused rather than written: a newline spills a second line the parser reads as a real field
1436
- // (`- include:` is exempt from the duplicate-identity STOP, and an `## …` spill truncates or bricks
1437
- // the whole section). Values reach here from the repo ROOT path and from --include, both of which
1438
- // may legally carry a newline on POSIX — so the guard is the only thing between them and a forged
1439
- // record. U+2028/U+2029 ride the same refusal: they are line terminators to the JS regex `.` but
1440
- // not to String.split('\n'), so such a value WRITES fine and is then silently DROPPED on read —
1441
- // a lost field with no error, which is the one outcome this codebase never allows.
1442
- // Fail closed: refuse to write, never sanitize silently.
1443
- const RECORD_CONTROL_BYTE = /[\u0000-\u001F\u007F\u2028\u2029]/;
1444
- const recordValue = (name, value) => {
1445
- const text = String(value);
1446
- if (RECORD_CONTROL_BYTE.test(text)) {
1447
- throw stop(`handoff record: the ${name} value carries a control character (newline/CR/NUL) — refusing to write a record whose fields could be forged by an injected line`);
1448
- }
1449
- // The parser `.trim()`s every value on read, and String.prototype.trim strips UNICODE whitespace
1450
- // — so an edge space (a Unicode one is legal even in a git branch name) writes fine and reads
1451
- // back as a DIFFERENT identity, stranding the worktree behind a record that no longer matches.
1452
- if (text !== text.trim()) {
1453
- throw stop(`handoff record: the ${name} value carries leading or trailing whitespace, which the record trims on read — the identity would change across a write→read round-trip: ${JSON.stringify(text)}`);
1454
- }
1455
- return text;
1456
- };
1457
-
1458
- // An OPTIONAL field is omitted when absent, never rendered as "null": a record written by an
1459
- // earlier kit is re-composed from its PARSED form at every refresh (land --prepare), so a field
1460
- // that kit never wrote must survive the round-trip as absence, not as a literal null string.
1461
- const optionalField = (name, value) => (value == null ? [] : [`- ${name}: ${recordValue(name, value)}`]);
1462
-
1463
- const composeProvisionRecordSection = ({ slug, branch, includes, nodeModules, vscode, install = null, sharedQueue = null, landing = null, prepared = null }) => [
1464
- '## Provision record',
1465
- '',
1466
- `- slug: ${recordValue('slug', slug)}`,
1467
- `- branch: ${recordValue('branch', branch)}`,
1468
- ...(includes.length === 0 ? ['- include: (none)'] : includes.map((p) => `- include: ${recordValue('include', p)}`)),
1469
- `- node_modules: ${recordValue('node_modules', nodeModules)}`,
1470
- `- vscode-settings: ${recordValue('vscode-settings', vscode)}`,
1471
- ...optionalField('install', install),
1472
- ...optionalField('shared-queue', sharedQueue),
1473
- ...optionalField('landing', landing),
1474
- ...optionalField('prepared-tree', prepared),
1475
- '',
1476
- // The rule says "at the absolute path above", so it ships only WITH that path: a record from an
1477
- // earlier kit carries no shared-queue field, and a rule pointing at nothing is worse than silence.
1478
- ...(sharedQueue == null ? [] : [QUEUE_SHARED_RULE, '']),
1479
- ].join('\n');
1480
-
1481
- export const composeHandoffStub = (fields) => [
1482
- `# Handoff — ${fields.slug}`,
1483
- '',
1484
- 'provisioned, nothing done yet',
1485
- '',
1486
- composeProvisionRecordSection(fields),
1487
- ].join('\n');
1488
-
1489
- const ATX_SECTION_HEADING = /^ {0,3}#{1,2} /;
1490
-
1491
- const locateProvisionRecordSection = (text) => {
1492
- const source = String(text);
1493
- const lines = [...source.matchAll(/.*(?:\r?\n|$)/g)].filter((match) => match[0] !== '');
1494
- const headings = lines.filter((match) => match[0].replace(/\r?\n$/, '').trim() === '## Provision record');
1495
- if (headings.length === 0) throw stop('handoff record: missing required "## Provision record" section');
1496
- if (headings.length > 1) throw stop('handoff record: multiple "## Provision record" sections — the record is ambiguous');
1497
- const start = headings[0].index;
1498
- const nextHeading = lines.find((match) => match.index > start && ATX_SECTION_HEADING.test(match[0].replace(/\r?\n$/, '')));
1499
- return { source, start, end: nextHeading?.index ?? source.length };
1500
- };
1501
-
1502
- // ONLY the required section is parsed, so decoy fields elsewhere cannot hijack identity.
1503
- // Duplicated single-valued fields are ambiguous identity → typed STOP, never last-wins.
1504
- export const parseProvisionRecord = (text) => {
1505
- const section = locateProvisionRecordSection(text);
1506
- const scan = section.source.slice(section.start, section.end).split('\n').slice(1);
1507
- const record = { slug: null, branch: null, includes: [], nodeModules: null, vscode: null, install: null, sharedQueue: null, landing: null, prepared: null };
1508
- const single = {
1509
- slug: 'slug', branch: 'branch', node_modules: 'nodeModules',
1510
- 'vscode-settings': 'vscode', 'prepared-tree': 'prepared',
1511
- install: 'install', 'shared-queue': 'sharedQueue', landing: 'landing',
1512
- };
1513
- const seen = new Set();
1514
- for (const line of scan) {
1515
- const m = line.match(/^- ([a-z_-]+): (.*)$/);
1516
- if (!m) continue;
1517
- const value = m[2].trim();
1518
- if (m[1] === 'include') {
1519
- if (value !== '(none)') record.includes.push(value);
1520
- continue;
1521
- }
1522
- const key = single[m[1]];
1523
- if (!key) continue;
1524
- if (seen.has(m[1])) throw stop(`handoff record: duplicate "${m[1]}" field — the record is ambiguous`);
1525
- seen.add(m[1]);
1526
- record[key] = value;
1527
- }
1528
- return record;
1529
- };
1530
-
1531
1375
  // Derived from MAIN's root, so the satellite reads an absolute path and a command that already
1532
- // cd-s back to main — neither is derivable from inside the worktree.
1376
+ // cd-s back to main — neither is derivable from inside the worktree. The landing COMMAND is its own
1377
+ // derivation because the cold-start prompt offers it as a runnable line while the record carries the
1378
+ // composed value; one source, so the two can never disagree.
1379
+ const landingCommand = ({ root, slug }) =>
1380
+ `${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`;
1381
+
1533
1382
  const orientationFields = ({ root, slug }) => ({
1534
1383
  sharedQueue: join(root, PLANS_REL, QUEUE_BASENAME),
1535
- landing: `${LANDING_FROM_MAIN} ${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`,
1384
+ landing: composeLandingValue({ rule: LANDING_FROM_MAIN, command: landingCommand({ root, slug }) }),
1536
1385
  });
1537
1386
 
1538
1387
  // Pre-mutation gate for everything the record will carry. `sharedQueue`/`landing` are derived from
@@ -1628,21 +1477,30 @@ const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report, journal
1628
1477
 
1629
1478
  // Validated BEFORE any git mutation — a bad --plan/--as never leaves a half-made worktree.
1630
1479
  const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
1480
+ // The --as ARGUMENT is checked first, before any diagnostic that would render it: a refusal is read
1481
+ // in the same terminal the cold-start prompt is. The --plan path needs no separate refusal — every
1482
+ // message below renders it through displayValue, and a hostile path reaches the derived-name guard
1483
+ // anyway, where the refusal is a runtime STOP because the offending value is a filesystem name and
1484
+ // not an argument. JSON.stringify is not the guard for either: it escapes C0 and passes C1 and
1485
+ // U+2028/U+2029 straight through.
1486
+ if (asFlag !== null && hasControlByte(asFlag)) {
1487
+ throw usageStop(`--as carries a control character, which would forge a line wherever it is rendered: ${displayValue(asFlag)}`);
1488
+ }
1631
1489
  if (asFlag !== null && (asFlag.includes('/') || asFlag.includes('\\') || !asFlag.endsWith('.md'))) {
1632
- throw usageStop(`--as must be a basename ending in .md, got ${JSON.stringify(asFlag)}`);
1490
+ throw usageStop(`--as must be a basename ending in .md, got ${displayValue(JSON.stringify(asFlag))}`);
1633
1491
  }
1634
1492
  const srcAbs = resolve(root, planFlag);
1635
1493
  const node = classifyNodeNoFollow(srcAbs, fs);
1636
- if (node.kind === 'absent') throw stop(`--plan: not found: ${planFlag}`);
1637
- if (node.kind === 'error') throw stop(`--plan: cannot inspect ${planFlag} (${node.error})`);
1638
- if (node.kind !== 'regular-file') throw stop(`--plan must be a regular non-symlink file: ${planFlag}`);
1494
+ if (node.kind === 'absent') throw stop(`--plan: not found: ${displayValue(planFlag)}`);
1495
+ if (node.kind === 'error') throw stop(`--plan: cannot inspect ${displayValue(planFlag)} (${node.error})`);
1496
+ if (node.kind !== 'regular-file') throw stop(`--plan must be a regular non-symlink file: ${displayValue(planFlag)}`);
1639
1497
  let srcReal;
1640
1498
  try {
1641
1499
  srcReal = fs.realpath(srcAbs);
1642
1500
  } catch {
1643
- throw stop(`--plan: not found: ${planFlag}`);
1501
+ throw stop(`--plan: not found: ${displayValue(planFlag)}`);
1644
1502
  }
1645
- if (!isInside(rootReal, srcReal)) throw stop(`--plan must resolve inside the main repo: ${planFlag}`);
1503
+ if (!isInside(rootReal, srcReal)) throw stop(`--plan must resolve inside the main repo: ${displayValue(planFlag)}`);
1646
1504
  if (normalizeSlashes(dirname(srcReal)) === normalizeSlashes(join(rootReal, PLANS_REL)) && !isScratchPlanName(basename(srcReal))) {
1647
1505
  throw stop(
1648
1506
  `--plan names a bare (in-flight) plan inside MAIN's ${PLANS_REL} — the feature plan must live in the satellite ONLY, ` +
@@ -1651,10 +1509,14 @@ const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
1651
1509
  );
1652
1510
  }
1653
1511
  const name = asFlag ?? basename(srcAbs);
1654
- if (!name.endsWith('.md')) throw stop(`the seeded plan name must end in .md: ${name}`);
1512
+ if (!name.endsWith('.md')) throw stop(`the seeded plan name must end in .md: ${displayValue(name)}`);
1513
+ // The derived name too: without --as it is the source basename, which the checks above never saw.
1514
+ if (hasControlByte(name)) {
1515
+ throw stop(`the seeded plan name carries a control character, which would forge a line in the satellite's cold-start prompt: ${displayValue(name)}`);
1516
+ }
1655
1517
  if (isScratchPlanName(name)) {
1656
1518
  throw stop(
1657
- `refusing to seed a scratch-class plan name (${name}) — the worktree's review-state would read it as "no plan ` +
1519
+ `refusing to seed a scratch-class plan name (${displayValue(name)}) — the worktree's review-state would read it as "no plan ` +
1658
1520
  'in flight" and every council check would pass vacuously. Seed a bare name via --as <name>.md.',
1659
1521
  );
1660
1522
  }
@@ -1669,7 +1531,7 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report, journal = NO_JOURNAL
1669
1531
  return;
1670
1532
  }
1671
1533
  const src = readFileNoFollow(fs, srcAbs);
1672
- if (!src.bytes) throw stop(`--plan: not readable as a regular file: ${srcAbs}`);
1534
+ if (!src.bytes) throw stop(`--plan: not readable as a regular file: ${displayValue(srcAbs)}`);
1673
1535
  guardDst(fs, wtRoot, dirname(dst));
1674
1536
  fs.mkdir(dirname(dst));
1675
1537
  writeContainedFileAtomic(wtRoot, dst, String(src.bytes), fs, { stop: (m) => stop(m) });
@@ -1877,17 +1739,156 @@ const declaresNoDependencies = ({ wtRoot, fs }) => {
1877
1739
  // an earlier provision left — an install through it writes into MAIN, and the posture must never
1878
1740
  // hide that). Only then may a PROVEN dependency-free checkout short-circuit: a verdict of
1879
1741
  // "nothing to install" must not ride an install instruction.
1880
- const resolveInstallPosture = ({ wtRoot, dependencyFree, fs }) => {
1742
+ const SYMLINK_POSTURE_HEAD = 'the provisioned node_modules is a symlink into MAIN (an install through it writes into MAIN)';
1743
+ // Two DIFFERENT facts, never one wording: a link whose target was read and is not MAIN's, and a link
1744
+ // whose target could not be read at all. Claiming the second points elsewhere would state something
1745
+ // nothing established; both withhold the removal advice for the same reason.
1746
+ // Stated as the PROVEN fact and no more: the raw target does not equal the absolute path provision
1747
+ // writes. A relative target may still resolve to the same directory, and an absolute one may be a
1748
+ // provisioned link left behind by a MAIN that moved — neither is disproved here, and claiming the
1749
+ // link "points somewhere else" or "was not placed by this tool" would assert what was never checked.
1750
+ const FOREIGN_NODE_MODULES_LINK = 'node_modules here is a symlink whose raw target is not the absolute MAIN node_modules path this tool writes, so its ownership is unproven — nothing is claimed about what an install through it would write, and no removal is advised; inspect it before installing';
1751
+ const UNREADABLE_NODE_MODULES_LINK = 'node_modules here is a symlink whose target could not be read, so this tool cannot tell whether it points at MAIN — nothing is claimed about it and no removal is advised; inspect it before installing';
1752
+ const TRACKED_NODE_MODULES_LINK = 'node_modules here is a TRACKED symlink — its target is MAIN node_modules, but a tracked path is repository content the landing lane protects, so no removal is advised; take it up with the checkout that tracked it';
1753
+ const LANE_UNPROVEN_NODE_MODULES_LINK = 'node_modules here is a symlink whose target IS MAIN node_modules, but this tool could not establish that the path sits in the ignored lane, and only an ignored matching link is the one provision places — so nothing is claimed about it and no removal is advised';
1754
+
1755
+ // ONE wording for every not-ours verdict, so the report and the prompt can never describe the same
1756
+ // link differently. The foreign target is decoded FATALLY: bytes that are not text are quoted
1757
+ // nowhere, and fall to the unreadable answer rather than to a replaced string.
1758
+ const unverifiedLinkPosture = (ownership) => {
1759
+ if (ownership.verdict === 'tracked') return TRACKED_NODE_MODULES_LINK;
1760
+ if (ownership.verdict === 'lane-unproven') return `${LANE_UNPROVEN_NODE_MODULES_LINK} (${ownership.error})`;
1761
+ if (ownership.verdict === 'unreadable') return `${UNREADABLE_NODE_MODULES_LINK} (${ownership.error})`;
1762
+ const decoded = decodeTargetStrictly(ownership.target);
1763
+ return decoded === null
1764
+ ? `${UNREADABLE_NODE_MODULES_LINK} (its target is not decodable text)`
1765
+ : `${FOREIGN_NODE_MODULES_LINK}: ${decoded}`;
1766
+ };
1767
+
1768
+ // Ownership is decided on the RAW TARGET BYTES, the same evidence cleanup binds on. A decoded string
1769
+ // is not that: two different byte sequences can decode to ONE string through UTF-8 replacement, and
1770
+ // the pair that collides would authorize removing a link this tool never placed. The outcome is
1771
+ // STRUCTURED because the three answers are different facts: ours, someone else's, or unknown — and
1772
+ // an unreadable link is the last of those, never a claim about where it points.
1773
+ const readLinkTarget = (fs, path) => {
1774
+ try {
1775
+ const raw = fs.readlink(path, { encoding: 'buffer' });
1776
+ return { target: Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw)) };
1777
+ } catch (err) {
1778
+ return { error: err?.code ?? 'fs error' };
1779
+ }
1780
+ };
1781
+
1782
+ // Fatal UTF-8: a target that is not decodable text is not a target this tool will quote. Returning
1783
+ // null keeps it out of the record and the prompt entirely, rather than quoting a replaced string.
1784
+ const FATAL_UTF8_TARGET = new TextDecoder('utf-8', { fatal: true });
1785
+ const decodeTargetStrictly = (buffer) => {
1786
+ try {
1787
+ return FATAL_UTF8_TARGET.decode(buffer);
1788
+ } catch {
1789
+ return null;
1790
+ }
1791
+ };
1792
+
1793
+ // The ONE ownership question, asked the same way by every lane that acts on that link:
1794
+ // 'ours' | 'foreign' | 'tracked' | 'unreadable', plus the raw target where one was read.
1795
+ //
1796
+ // Matching bytes are HALF the proof. The cleanup ownership rule states the other half — only a
1797
+ // matching link IN THE IGNORED LANE is provision-ephemeral — and it is the half that decides whether
1798
+ // removal may be advised at all: a TRACKED link at this path is repository content, and offering to
1799
+ // delete it would advise destroying something the landing lane protects. A lane the probe cannot
1800
+ // establish is not the ignored lane either.
1801
+ const nodeModulesLinkOwnership = ({ fs, git, nmPath, wtRoot, mainRoot }) => {
1802
+ const read = readLinkTarget(fs, nmPath);
1803
+ if (read.error !== undefined) return { verdict: 'unreadable', error: read.error };
1804
+ if (Buffer.compare(read.target, Buffer.from(join(mainRoot, NODE_MODULES_REL))) !== 0) {
1805
+ return { verdict: 'foreign', target: read.target };
1806
+ }
1807
+ const lane = probeOwnedLane({ git, wtRoot, rel: NODE_MODULES_REL });
1808
+ if (lane.lane === 'ignored') return { verdict: 'ours', target: read.target };
1809
+ if (lane.lane === 'tracked') return { verdict: 'tracked', target: read.target };
1810
+ // The target WAS read here — only the lane is unsettled — so this must not borrow the wording of a
1811
+ // failed target read. Its cause is the lane probe's own: an untracked path, or a probe that could
1812
+ // not answer at all.
1813
+ return {
1814
+ verdict: 'lane-unproven',
1815
+ error: lane.detail ?? `the path is ${lane.lane}, and only an ignored one is the link provision places`,
1816
+ target: read.target,
1817
+ };
1818
+ };
1819
+ const INSTALL_RUNNABLE_DESCRIPTION = 'this checkout installs its own dependencies — the command below runs in it';
1820
+
1821
+ // Three views of ONE probe, so the record and the cold-start prompt can never disagree about this
1822
+ // checkout: `posture` is the RECORD's field, byte-for-byte what it has always been; `description` is
1823
+ // the prose half with no command in it; `command` is the runnable half, or null where none exists.
1824
+ // The split is what keeps a runnable install out of an unattributed prompt line — the posture string
1825
+ // IS a command in the ordinary case, so rendering it as prose would offer an instruction nothing
1826
+ // attributes and no command parser can see.
1827
+ const resolveInstall = ({ wtRoot, mainRoot, dependencyFree, fs, git }) => {
1881
1828
  const nmPath = join(wtRoot, 'node_modules');
1882
1829
  const nm = lstatNoFollow(fs.lstat, nmPath);
1830
+ const removal = `rm ${shellQuoteArg(nmPath)}`;
1883
1831
  if (nm !== null && nm.isSymbolicLink()) {
1832
+ // A symlink is not proof of THIS tool's link. The provisioned one points at MAIN's
1833
+ // node_modules; anything else is a node the session (or a later hand) put there, and claiming
1834
+ // "a symlink into MAIN" about it would state a live fact nothing checked — and then advise
1835
+ // removing something this tool never placed. Ownership is decided by the raw target bytes,
1836
+ // the same evidence cleanup binds on.
1837
+ const ownership = nodeModulesLinkOwnership({ fs, git, nmPath, wtRoot, mainRoot });
1838
+ if (ownership.verdict !== 'ours') {
1839
+ // The target reaches the record's own value guard and the prompt's, and each refuses a hostile
1840
+ // one by NAME — so it is decoded FATALLY here: a lossy decode would fold undecodable bytes to
1841
+ // U+FFFD and hand both guards a sanitized string, a silent pass where a typed STOP was
1842
+ // promised. Bytes that are not text at all get the same treatment as an unreadable link:
1843
+ // nothing is claimed about them. displayValue belongs in diagnostics only.
1844
+ const unverified = unverifiedLinkPosture(ownership);
1845
+ return { posture: unverified, description: unverified, command: null };
1846
+ }
1884
1847
  const advice = resolveInstallAdvice({ wtRoot, fs });
1885
1848
  const separator = advice.command === null ? ' — ' : ' && ';
1886
- 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}`;
1849
+ // The REMOVAL is runnable even when no install command is derivable, so it rides the attributed
1850
+ // line either way and never sits loose inside prose.
1851
+ return {
1852
+ posture: `${SYMLINK_POSTURE_HEAD} — for isolation remove it first: ${removal}${separator}${advice.instruction}`,
1853
+ description: advice.command === null
1854
+ ? `${SYMLINK_POSTURE_HEAD} — for isolation remove it first with the command below; then ${NEUTRAL_INSTALL_ADVICE}`
1855
+ : `${SYMLINK_POSTURE_HEAD} — for isolation remove it first and install; the command below does both`,
1856
+ command: advice.command === null ? removal : `${removal} && ${advice.command}`,
1857
+ };
1887
1858
  }
1888
- if (dependencyFree) return NO_DEPENDENCIES_POSTURE;
1889
- return resolveInstallAdvice({ wtRoot, fs }).instruction;
1890
- };
1859
+ if (dependencyFree) {
1860
+ return { posture: NO_DEPENDENCIES_POSTURE, description: NO_DEPENDENCIES_POSTURE, command: null };
1861
+ }
1862
+ const advice = resolveInstallAdvice({ wtRoot, fs });
1863
+ return advice.command === null
1864
+ ? { posture: advice.instruction, description: advice.instruction, command: null }
1865
+ : { posture: advice.instruction, description: INSTALL_RUNNABLE_DESCRIPTION, command: advice.command };
1866
+ };
1867
+
1868
+
1869
+ // The satellite's cold-start prompt, composed from LIVE facts at both print sites (D16): provision
1870
+ // ends its report with it, and `prompt <slug>` re-prints it later from MAIN. The record is passed in
1871
+ // only so a value FROZEN at provision time can be named where it no longer matches.
1872
+ //
1873
+ // It PROBES NOTHING. The satellite-derived facts — the seeded plan and the install posture — are
1874
+ // arguments, because provision has already established both by the time it composes and a second
1875
+ // read there would be a fresh failure window after the work is done; `prompt` resolves them itself,
1876
+ // where a failure is the whole outcome of the run.
1877
+ const composeSatellitePromptFor = ({ root, wtRoot, slug, branch, record, plan, install }) => composeSatellitePrompt({
1878
+ slug,
1879
+ branch,
1880
+ worktreePath: wtRoot,
1881
+ plan,
1882
+ live: {
1883
+ sharedQueue: orientationFields({ root, slug }).sharedQueue,
1884
+ landingRule: LANDING_FROM_MAIN,
1885
+ landingCommand: landingCommand({ root, slug }),
1886
+ installPosture: install.posture,
1887
+ installDescription: install.description,
1888
+ installCommand: install.command,
1889
+ },
1890
+ record,
1891
+ });
1891
1892
 
1892
1893
  const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report, journal = NO_JOURNAL }) => {
1893
1894
  // The lane places ONLY a symlink, so the kind gate admits only a symlink at this path: a
@@ -1898,6 +1899,16 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyF
1898
1899
  const dst = join(wtRoot, NODE_MODULES_REL);
1899
1900
  const existing = lstatNoFollow(fs.lstat, dst);
1900
1901
  if (existing !== null && existing.isSymbolicLink()) {
1902
+ // The unlink-first advice belongs to OUR link and to no other: for a link this tool never
1903
+ // placed it would offer to delete a node whose target it has not established, and the
1904
+ // cold-start prompt would then contradict the report in the same breath. Same ownership
1905
+ // question, same raw-bytes evidence.
1906
+ const ownership = nodeModulesLinkOwnership({ fs, git, nmPath: dst, wtRoot, mainRoot: root });
1907
+ if (ownership.verdict !== 'ours') {
1908
+ journalLink('kept');
1909
+ report.push(` node_modules: ${displayValue(unverifiedLinkPosture(ownership))}`);
1910
+ return 'install-printed-unverified-link';
1911
+ }
1901
1912
  // isolation only exists BEFORE the link: an install through it would write into MAIN
1902
1913
  const separator = install.command === null ? ' — ' : ' && ';
1903
1914
  journalLink('kept');
@@ -2254,7 +2265,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2254
2265
  const inFlight = plansInFlight(targetPath, fs.readdir);
2255
2266
  if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
2256
2267
  throw stop(
2257
- `the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.join(', ')}] — remove the extras (or re-seed) and re-run --resume`,
2268
+ `the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.map(displayValue).join(', ')}] — remove the extras (or re-seed) and re-run --resume`,
2258
2269
  );
2259
2270
  }
2260
2271
 
@@ -2278,6 +2289,33 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2278
2289
  }
2279
2290
  }
2280
2291
 
2292
+ // ONE probe, three views: the record takes the posture, the prompt takes the prose and the
2293
+ // runnable half. A second probe here could disagree with what the record is about to state.
2294
+ const install = resolveInstall({ wtRoot: targetPath, mainRoot: root, dependencyFree, fs, git });
2295
+ const fields = {
2296
+ slug,
2297
+ branch,
2298
+ includes: includesRecorded,
2299
+ nodeModules: nodeModulesMode,
2300
+ vscode: vscodeMode,
2301
+ install: install.posture,
2302
+ ...orientationFields({ root, slug }),
2303
+ };
2304
+ // Composed BEFORE the record is written and before ANY output: the values are already established
2305
+ // (the seeded plan passed the EXACTLY-ONE check above, the install posture is the one going into
2306
+ // the record), so a composition failure lands where every other late provision failure lands —
2307
+ // with the record bytes untouched and no success line printed — instead of contradicting a success
2308
+ // message it would otherwise follow.
2309
+ const prompt = composeSatellitePromptFor({
2310
+ root,
2311
+ wtRoot: targetPath,
2312
+ slug,
2313
+ branch,
2314
+ record: fields,
2315
+ plan: seed.name,
2316
+ install,
2317
+ });
2318
+
2281
2319
  // The record refresh runs LAST, after the in-flight check and the verify, in BOTH lanes —
2282
2320
  // the record attests only a VERIFIED provision; a failed run leaves the prior record bytes
2283
2321
  // (the stub on a failed first provision). On resume the generic kept-worktree NOTE does not
@@ -2289,15 +2327,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2289
2327
  slug,
2290
2328
  branch,
2291
2329
  journal,
2292
- fields: {
2293
- slug,
2294
- branch,
2295
- includes: includesRecorded,
2296
- nodeModules: nodeModulesMode,
2297
- vscode: vscodeMode,
2298
- install: resolveInstallPosture({ wtRoot: targetPath, dependencyFree, fs }),
2299
- ...orientationFields({ root, slug }),
2300
- },
2330
+ fields,
2301
2331
  fs,
2302
2332
  report,
2303
2333
  });
@@ -2312,6 +2342,10 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2312
2342
  for (const line of report) log(line);
2313
2343
  log(`[worktrees] provisioned ${slug} at ${targetPath} (branch ${branch}, base ${base})`);
2314
2344
  log(`open it: code -n ${shellQuoteArg(targetPath)}`);
2345
+ // The report ENDS with the satellite's cold-start prompt: the facts a fresh session in that
2346
+ // checkout cannot derive are worth nothing if they are only re-derivable on request.
2347
+ log('');
2348
+ log(prompt);
2315
2349
  return EXIT.ok;
2316
2350
  };
2317
2351
 
@@ -2362,6 +2396,30 @@ export const runList = ({ cwd, git, deps, log }) => {
2362
2396
  return EXIT.ok;
2363
2397
  };
2364
2398
 
2399
+ // ── prompt ─────────────────────────────────────────────────────────────────────────────
2400
+
2401
+ // Read-only: it resolves the satellite, proves the handoff identity there, and prints. Nothing is
2402
+ // written, and MAIN is where it must run — the orientation it composes is MAIN's, so the
2403
+ // linked-worktree refusal in resolveRoots is the guard that keeps it honest.
2404
+ export const runPrompt = ({ argvSlug, cwd, git, deps, log }) => {
2405
+ const fs = fsOf(deps);
2406
+ const slug = validateSlug(argvSlug);
2407
+ const { root } = resolveRoots(cwd, git);
2408
+ const entry = findSatelliteEntry({ root, slug, branch: null, git, fs });
2409
+ const identity = readSatelliteIdentity({ entry, slug, fs });
2410
+ const wtRoot = entry.path;
2411
+ log(composeSatellitePromptFor({
2412
+ root,
2413
+ wtRoot,
2414
+ slug,
2415
+ branch: identity.branch,
2416
+ record: identity.record,
2417
+ plan: resolveSeededPlan({ wtRoot, readdir: fs.readdir }),
2418
+ install: resolveInstall({ wtRoot, mainRoot: root, dependencyFree: declaresNoDependencies({ wtRoot, fs }), fs, git }),
2419
+ }));
2420
+ return EXIT.ok;
2421
+ };
2422
+
2365
2423
  // ── land + cleanup ────────────────────────────────────────────────────────────────────
2366
2424
 
2367
2425
  const nulFields = (text) => String(text).split('\0').filter((field) => field !== '');
@@ -2433,57 +2491,6 @@ const withPrepareLock = ({ commonDir, fs, now }, action) => {
2433
2491
  return result;
2434
2492
  };
2435
2493
 
2436
- const branchNameOf = (entry) => entry.branch?.replace(/^refs\/heads\//, '') ?? null;
2437
-
2438
- const findSatelliteEntry = ({ root, slug, branch, git, fs }) => {
2439
- const entries = listWorktrees(git, root).slice(1);
2440
- const exactHandoff = [];
2441
- for (const entry of entries) {
2442
- if (entry.prunable) continue;
2443
- const scan = scanPlansDir({ wtRoot: entry.path, fs });
2444
- if (scan.state === 'ok' && scan.handoffs.includes(handoffBasename(slug))) exactHandoff.push(entry);
2445
- }
2446
- if (exactHandoff.length > 1) {
2447
- throw stop(`multiple worktrees carry ${handoffBasename(slug)} — cleanup the duplicate identity before continuing`);
2448
- }
2449
- if (branch !== null) {
2450
- const byBranch = entries.filter((entry) => entry.branch === `refs/heads/${branch}`);
2451
- if (byBranch.length > 1) throw stop(`multiple worktrees claim branch ${branch}`);
2452
- if (byBranch.length === 1) return byBranch[0];
2453
- }
2454
- if (exactHandoff.length === 1) return exactHandoff[0];
2455
- const fallback = entries.filter((entry) => entry.branch === `refs/heads/${DEFAULT_BRANCH_PREFIX}${slug}`);
2456
- if (fallback.length === 1) return fallback[0];
2457
- throw stop(`no registered satellite worktree for ${slug}`);
2458
- };
2459
-
2460
- const readSatelliteIdentity = ({ entry, slug, expectedBranch, fs, abandon = false }) => {
2461
- const name = handoffBasename(slug);
2462
- const scan = scanPlansDir({ wtRoot: entry.path, fs });
2463
- if (scan.state === 'ok' && scan.nonRegular.includes(name)) {
2464
- throw stop(`handoff identity mismatch: ${name} is not a regular file`);
2465
- }
2466
- if (scan.state !== 'ok' || !scan.handoffs.includes(name)) {
2467
- if (abandon) throw stop(`${name} is absent — force deletion is forbidden without the handoff identity`);
2468
- throw stop(`handoff identity mismatch: expected ${name} in the satellite`);
2469
- }
2470
- if (scan.handoffs.length !== 1) {
2471
- throw stop(`handoff identity mismatch: expected exactly ${name}, found [${scan.handoffs.join(', ')}]`);
2472
- }
2473
- const leaf = readFileNoFollow(fs, join(entry.path, PLANS_REL, name));
2474
- if (!leaf.bytes) throw stop(`handoff identity mismatch: ${name} is not readable as a regular file`);
2475
- const record = parseProvisionRecord(String(leaf.bytes));
2476
- const liveBranch = branchNameOf(entry);
2477
- const wantedBranch = expectedBranch ?? liveBranch;
2478
- if (record.slug !== slug || record.branch !== wantedBranch || liveBranch !== wantedBranch) {
2479
- throw stop(
2480
- `handoff identity mismatch: expected slug ${slug} and branch ${wantedBranch}; ` +
2481
- `record has slug ${record.slug ?? '(missing)'} and branch ${record.branch ?? '(missing)'}, live branch ${liveBranch ?? '(detached)'}`,
2482
- );
2483
- }
2484
- return { record, path: join(entry.path, PLANS_REL, name), branch: wantedBranch };
2485
- };
2486
-
2487
2494
  const changedPaths = (git, args, cwd, label) =>
2488
2495
  nulFields(gitRead(git, [...args, '-z', '--', ...TRANSFER_EXCLUSIONS], cwd, label).stdout);
2489
2496
 
@@ -2689,12 +2696,15 @@ const runSyncAdapter = ({ root, mainHead, transferPaths, git, fs, deps, report }
2689
2696
  return delta;
2690
2697
  };
2691
2698
 
2692
- const recordPreparedTree = ({ identity, slug, entry, prepared, fs }) => {
2699
+ // prepared-head rides the SAME record refresh as prepared-tree (D8): MAIN's HEAD at prepare time
2700
+ // is what lets the return rung tell a still-pending prepared set from an already-committed one —
2701
+ // a clean post-commit index reproduces the committed tree, so the tree OID alone cannot.
2702
+ const recordPreparedTree = ({ identity, slug, entry, prepared, preparedHead, fs }) => {
2693
2703
  writeHandoffRecord({
2694
2704
  wtRoot: entry.path,
2695
2705
  slug,
2696
2706
  branch: identity.branch,
2697
- fields: { ...identity.record, prepared },
2707
+ fields: { ...identity.record, prepared, preparedHead },
2698
2708
  fs,
2699
2709
  report: [],
2700
2710
  });
@@ -2714,12 +2724,12 @@ const dirtyMainStop = ({ root, git, record, porcelain }) => {
2714
2724
  const hasTrackedUnstaged = trackedEntries.some((entry) => entry.code[1] !== ' ');
2715
2725
  const mayReset = converged && !hasTrackedUnstaged;
2716
2726
  const classification = converged
2717
- ? `converged re-run: current staged write-tree matches the previous prepare's recorded OID ${record.prepared}`
2727
+ ? `converged re-run: current staged write-tree matches the previous prepare's recorded OID ${displayValue(record.prepared)}`
2718
2728
  : record.prepared === null
2719
2729
  ? 'foreign staged work: no previous prepare OID is recorded'
2720
2730
  : treeMatchesRecord
2721
- ? `foreign staged work: the index has no staged delta against HEAD, although its write-tree matches the recorded OID ${record.prepared}`
2722
- : `foreign staged work: current staged write-tree differs from the previous prepare's recorded OID ${record.prepared}`;
2731
+ ? `foreign staged work: the index has no staged delta against HEAD, although its write-tree matches the recorded OID ${displayValue(record.prepared)}`
2732
+ : `foreign staged work: current staged write-tree differs from the previous prepare's recorded OID ${displayValue(record.prepared)}`;
2723
2733
  const leftoversReport = leftovers.length === 0
2724
2734
  ? []
2725
2735
  : mayReset
@@ -2788,7 +2798,7 @@ export const runLand = ({ argvSlug, flags, cwd, git, deps, log }) => {
2788
2798
  const syncDelta = runSyncAdapter({ root, mainHead, transferPaths, git, fs, deps, report });
2789
2799
  const preparedTree = gitRead(git, ['write-tree'], root, 'cannot write the prepared main tree').stdout.trim();
2790
2800
  try {
2791
- recordPreparedTree({ identity, slug, entry, prepared: preparedTree, fs });
2801
+ recordPreparedTree({ identity, slug, entry, prepared: preparedTree, preparedHead: mainHead, fs });
2792
2802
  } catch (error) {
2793
2803
  throw withRollbackFailures(error, rollbackMain({ root, mainHead, git, fs }));
2794
2804
  }
@@ -2876,7 +2886,7 @@ const registryRoots = () => {
2876
2886
  const safeRecordedPath = (path) => {
2877
2887
  const normalized = normalizeSlashes(String(path)).replace(/^\.\//, '').replace(/\/$/, '');
2878
2888
  if (!normalized || isAbsolute(normalized) || normalized.split('/').includes('..')) {
2879
- throw stop(`handoff record carries an unsafe provision path: ${path}`);
2889
+ throw stop(`handoff record carries an unsafe provision path: ${displayValue(path)}`);
2880
2890
  }
2881
2891
  return normalized;
2882
2892
  };
@@ -3196,12 +3206,13 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
3196
3206
  export const parseArgs = (argv) => {
3197
3207
  const [sub, ...rest] = argv;
3198
3208
  if (sub === undefined || sub === '--help' || sub === '-h') return { sub: 'help' };
3199
- if (!['provision', 'list', 'land', 'cleanup'].includes(sub)) {
3209
+ if (!['provision', 'list', 'prompt', 'land', 'cleanup'].includes(sub)) {
3200
3210
  throw usageStop(`unknown subcommand ${JSON.stringify(sub)}\n${USAGE}`);
3201
3211
  }
3202
3212
  const SUB_FLAGS = {
3203
3213
  provision: ['--plan', '--as', '--dir', '--branch', '--include', '--install', '--resume'],
3204
3214
  list: [],
3215
+ prompt: [],
3205
3216
  land: ['--prepare'],
3206
3217
  cleanup: ['--branch', '--abandon'],
3207
3218
  };
@@ -3245,6 +3256,7 @@ export const runCli = (argv, deps = {}) => {
3245
3256
  return runProvision({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
3246
3257
  }
3247
3258
  if (parsed.sub === 'list') return runList({ cwd, git, deps, log });
3259
+ if (parsed.sub === 'prompt') return runPrompt({ argvSlug: parsed.slug, cwd, git, deps, log });
3248
3260
  if (parsed.sub === 'land') {
3249
3261
  return runLand({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
3250
3262
  }
@@ -3255,5 +3267,4 @@ export const runCli = (argv, deps = {}) => {
3255
3267
  }
3256
3268
  };
3257
3269
 
3258
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
3259
- if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));
3270
+ if (isDirectRun(import.meta.url)) process.exitCode = runCli(process.argv.slice(2));