@sabaiway/agent-workflow-kit 5.9.0 → 5.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +1 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +32 -11
  5. package/bridges/antigravity-cli-bridge/bin/agy-envelope.mjs +160 -0
  6. package/bridges/antigravity-cli-bridge/bin/agy-envelope.test.mjs +235 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +23 -1
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +242 -38
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +482 -38
  10. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  11. package/bridges/antigravity-cli-bridge/references/models-and-flags.md +45 -12
  12. package/bridges/antigravity-cli-bridge/references/review-prompt.md +6 -3
  13. package/bridges/antigravity-cli-bridge/setup/README.md +18 -5
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +1 -1
  15. package/capability.json +1 -1
  16. package/package.json +1 -1
  17. package/references/hooks/state-block-guard.mjs +107 -45
  18. package/references/modes/commit-guard.md +11 -8
  19. package/references/modes/core-evidence.md +1 -1
  20. package/references/modes/dispatch.md +32 -10
  21. package/references/modes/set-recipe.md +8 -5
  22. package/references/modes/state-block-guard.md +39 -31
  23. package/references/modes/worktrees.md +47 -3
  24. package/references/scripts/check-docs-size-cli.test.mjs +2 -2
  25. package/references/shared/report-footer.md +2 -2
  26. package/references/templates/agent_rules.md +1 -0
  27. package/tools/advisor-matrix.mjs +165 -0
  28. package/tools/commands.mjs +2 -2
  29. package/tools/commit-guard.mjs +74 -17
  30. package/tools/core-evidence.mjs +10 -0
  31. package/tools/detect-backends.mjs +1 -0
  32. package/tools/dispatch-advisor.mjs +323 -0
  33. package/tools/dispatch.mjs +174 -109
  34. package/tools/doc-parity.mjs +68 -14
  35. package/tools/ensure-configs.mjs +4 -4
  36. package/tools/flow-check-cores.mjs +35 -6
  37. package/tools/flow-check-rungs.mjs +20 -2
  38. package/tools/flow-check.mjs +20 -5
  39. package/tools/lens-region.mjs +13 -1
  40. package/tools/observation-builder.mjs +123 -0
  41. package/tools/satellite-locator.mjs +179 -0
  42. package/tools/source-size-scope.mjs +3 -1
  43. package/tools/worktree-handoff-return.mjs +369 -0
  44. package/tools/worktree-prompt.mjs +190 -0
  45. package/tools/worktrees-record.mjs +171 -0
  46. package/tools/worktrees.mjs +308 -297
@@ -24,20 +24,35 @@ import { isScratchPlanName, plansInFlight, PLANS_REL, shellQuoteArg } from './re
24
24
  import { writeContainedFileAtomic } from './atomic-write.mjs';
25
25
  import { assertContainedRealPath } from './fs-safe.mjs';
26
26
  import { isFinalCapableDeclaration } from './run-gates.mjs';
27
+ import {
28
+ WORKTREES_STOP, stop, EXIT, handoffBasename, recordValue, hasControlByte, displayValue,
29
+ composeProvisionRecordSection, composeLandingValue, composeHandoffStub,
30
+ locateProvisionRecordSection, parseProvisionRecord,
31
+ } from './worktrees-record.mjs';
32
+ import {
33
+ DEFAULT_BRANCH_PREFIX, listWorktrees, classifyNodeNoFollow, scanPlansDir, findSatelliteEntry,
34
+ readSatelliteIdentity,
35
+ } from './satellite-locator.mjs';
36
+ import { composeSatellitePrompt, resolveSeededPlan } from './worktree-prompt.mjs';
37
+
38
+ // The record format and the satellite locator now live in leaves the dispatch side reads too — but
39
+ // every name they took away is re-exported here, so no import site and no asserted error `code`
40
+ // moved when they left.
41
+ export {
42
+ WORKTREES_STOP, stop, EXIT, handoffBasename, QUEUE_SHARED_RULE, composeHandoffStub,
43
+ parseProvisionRecord,
44
+ } from './worktrees-record.mjs';
45
+ export {
46
+ DEFAULT_BRANCH_PREFIX, parseWorktreeList, findSatelliteEntry, readSatelliteIdentity,
47
+ } from './satellite-locator.mjs';
27
48
 
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
49
  const usageStop = (message) => stop(message, { exitCode: EXIT.usage });
32
50
  const errorText = (error) => String(error?.message ?? error).replace(/^\[agent-workflow-kit\] /, '');
33
51
  const composeFailure = (primary, secondaryName, secondary) =>
34
52
  stop(`${errorText(primary)}; ${secondaryName} failed: ${errorText(secondary)}`);
35
53
 
36
- export const EXIT = Object.freeze({ ok: 0, stop: 1, usage: 2 });
37
54
  export const CONFIG_REL = 'docs/ai/worktrees.json';
38
55
  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
56
  const WORKTREES_TOOL_ABS = fileURLToPath(import.meta.url);
42
57
  const WORKTREES_TOOL_DIR = dirname(WORKTREES_TOOL_ABS);
43
58
 
@@ -67,6 +82,10 @@ const USAGE = [
67
82
  ` the ${CONFIG_REL} "parentDir" setting when present). --install only PRINTS the`,
68
83
  ' install command. --resume completes a half-done provision (identity-checked).',
69
84
  ' list show every worktree of this repo: slug, path, branch, base OID, dirty, handoff.',
85
+ ' prompt <slug>',
86
+ ' re-print the satellite cold-start prompt for a provisioned worktree: where it is,',
87
+ ' what MAIN answers now (derived LIVE, a stale record value is named), the handoff as',
88
+ ' the one return channel, and the bars. Read-only — it writes nothing.',
70
89
  ' land <slug> --prepare',
71
90
  ' stage the satellite diff onto a clean main (no commit — the commit stays a',
72
91
  ' dialogue ask). Refuses divergence, incomplete satellite state, or a dirty main.',
@@ -74,13 +93,21 @@ const USAGE = [
74
93
  ' remove a LANDED worktree (fail-closed verification); --abandon is the one',
75
94
  ' destructive arm and destroys unlanded work.',
76
95
  '',
77
- 'The slug is REQUIRED and positional on provision/land/cleanup: lowercase letters, digits,',
96
+ 'The slug is REQUIRED and positional on provision/prompt/land/cleanup: lowercase letters, digits,',
78
97
  'hyphens, max 64 chars, letter/digit first. Exit codes: 0 ok / 1 refusal / 2 usage.',
79
98
  ].join('\n');
80
99
 
81
100
  // ── deps + git plumbing (every seam injectable for hermetic tests) ─────────────────────
82
101
 
83
- const fsOf = (deps) => ({
102
+ const fsOf = (deps) => {
103
+ const fs = fsSeams(deps);
104
+ // The ONE content read, bound to THIS seam set and handed to the locator leaf, which owns no read
105
+ // door of its own — one body, one place, provable by the tripwires below it.
106
+ fs.readFileNoFollow = (abs) => readFileNoFollow(fs, abs);
107
+ return fs;
108
+ };
109
+
110
+ const fsSeams = (deps) => ({
84
111
  lstat: deps.lstat ?? lstatSync,
85
112
  mkdir: deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true })),
86
113
  mkdirPlain: deps.mkdirPlain ?? mkdirSync,
@@ -133,43 +160,9 @@ const lstatNoFollow = (lstat, path) => {
133
160
  }
134
161
  };
135
162
 
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
- };
163
+ // classifyNodeNoFollow moved to satellite-locator.mjs; the ONE content-read door stays HERE, and the
164
+ // locator receives it through its injected fs seam — a second body anywhere is exactly what the
165
+ // door tripwires exist to prevent.
173
166
 
174
167
  // The ONE content-read door: no-follow lstat, then an O_NOFOLLOW|O_NONBLOCK descriptor with an
175
168
  // fstat recheck — a node swapped after the lstat can neither follow a link nor block on a FIFO.
@@ -541,36 +534,8 @@ export const realpathThroughExistingParent = (target, deps = {}) => {
541
534
 
542
535
  // ── roots + worktree registry ──────────────────────────────────────────────────────────
543
536
 
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
- };
537
+ // parseWorktreeList and listWorktrees moved to satellite-locator.mjs with the resolver that needs
538
+ // them; both are re-exported above, so every caller and test import site is unchanged.
574
539
 
575
540
  // The MAIN worktree is the first `git worktree list --porcelain -z` entry; provision/land/cleanup
576
541
  // refuse to run from inside a linked worktree.
@@ -994,33 +959,8 @@ const assertTargetOutsideSources = ({ targetReal, sources }) => {
994
959
 
995
960
  // ── the shared plans-chain scanner (resume identity + list ride the SAME no-follow walk) ─
996
961
 
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
- };
962
+ // scanPlansDir moved to satellite-locator.mjs the resolver is its heaviest caller, and the
963
+ // dispatch side needs the same walk to find a satellite without importing this tool.
1024
964
 
1025
965
  // Resume writes NOTHING before this: the existing handoff must be the live identity.
1026
966
  const assertResumeHandoffIdentity = ({ wtRoot, slug, branch, fs }) => {
@@ -1030,24 +970,24 @@ const assertResumeHandoffIdentity = ({ wtRoot, slug, branch, fs }) => {
1030
970
  }
1031
971
  if (scan.state === 'absent') return;
1032
972
  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`);
973
+ 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
974
  }
1035
975
  if (scan.handoffs.length === 0) return;
1036
976
  if (scan.handoffs.length > 1) {
1037
- throw stop(`--resume: multiple handoff files found (${scan.handoffs.join(', ')}) — exactly one may exist`);
977
+ throw stop(`--resume: multiple handoff files found (${scan.handoffs.map(displayValue).join(', ')}) — exactly one may exist`);
1038
978
  }
1039
979
  const name = scan.handoffs[0];
1040
980
  if (name !== handoffBasename(slug)) {
1041
- throw stop(`--resume identity mismatch: the existing handoff is ${name}, the live slug is ${slug} (${handoffBasename(slug)})`);
981
+ throw stop(`--resume identity mismatch: the existing handoff is ${displayValue(name)}, the live slug is ${slug} (${handoffBasename(slug)})`);
1042
982
  }
1043
983
  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`);
984
+ if (!rf.bytes) throw stop(`--resume: the handoff ${displayValue(name)} is not readable as a regular file — fix it before resuming`);
1045
985
  const record = parseProvisionRecord(String(rf.bytes));
1046
986
  if (record.slug !== slug) {
1047
- throw stop(`--resume identity mismatch: the handoff record slug is ${record.slug ?? '(missing)'}, the live slug is ${slug}`);
987
+ throw stop(`--resume identity mismatch: the handoff record slug is ${record.slug === null ? '(missing)' : displayValue(record.slug)}, the live slug is ${slug}`);
1048
988
  }
1049
989
  if (record.branch !== branch) {
1050
- throw stop(`--resume identity mismatch: the handoff record branch is ${record.branch ?? '(missing)'}, the live branch is ${branch}`);
990
+ throw stop(`--resume identity mismatch: the handoff record branch is ${record.branch === null ? '(missing)' : displayValue(record.branch)}, the live branch is ${branch}`);
1051
991
  }
1052
992
  };
1053
993
 
@@ -1056,12 +996,12 @@ const assertResumePlanCompatibility = ({ wtRoot, seedName, fs }) => {
1056
996
  if (inFlight.length === 0 || (inFlight.length === 1 && inFlight[0] === seedName)) return;
1057
997
  if (inFlight.length === 1) {
1058
998
  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`,
999
+ `--resume plan mismatch: found [${displayValue(inFlight[0])}], expected [${displayValue(seedName)}] or no in-flight plan — ` +
1000
+ `re-run with --as ${displayValue(inFlight[0])}, or remove the existing plan by hand`,
1061
1001
  );
1062
1002
  }
1063
1003
  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`,
1004
+ `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
1005
  );
1066
1006
  };
1067
1007
 
@@ -1222,8 +1162,8 @@ const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels,
1222
1162
  const rf = readFileNoFollow(fs, join(wtRoot, PLANS_REL, handoffBasename(slug)));
1223
1163
  if (!rf.bytes) return { binds: false, reason: 'the handoff is not readable as a regular file' };
1224
1164
  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}` };
1165
+ if (record.slug !== slug) return { binds: false, reason: `the record slug is ${record.slug === null ? '(missing)' : displayValue(record.slug)}, the live slug is ${slug}` };
1166
+ 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
1167
  return { binds: true, reason: null };
1228
1168
  } catch (err) {
1229
1169
  return { binds: false, reason: errorText(err) };
@@ -1287,8 +1227,8 @@ const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels,
1287
1227
  // The orientation facts a fresh satellite session cannot derive from its own checkout. They are
1288
1228
  // CONSTANTS so the doc-parity registry can pin the mode doc to the exact strings the tool emits.
1289
1229
  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';
1230
+ // QUEUE_SHARED_RULE travels with the composer that emits it (worktrees-record.mjs) and is
1231
+ // re-exported above; LANDING_FROM_MAIN stays where its only user is.
1292
1232
  export const LANDING_FROM_MAIN = 'landing runs FROM MAIN, never from this worktree';
1293
1233
  export const NO_DEPENDENCIES_POSTURE = 'no install needed — the project declares no dependencies';
1294
1234
  // The recorded node_modules mode for that same verdict: provision neither advised nor created a
@@ -1431,108 +1371,16 @@ const verifyPlacedPaths = ({ git, wtRoot, members }) => {
1431
1371
  if (failures.length > 0) throw stop(composeOwnedVerifyStop(failures));
1432
1372
  };
1433
1373
 
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
1374
  // 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.
1375
+ // cd-s back to main — neither is derivable from inside the worktree. The landing COMMAND is its own
1376
+ // derivation because the cold-start prompt offers it as a runnable line while the record carries the
1377
+ // composed value; one source, so the two can never disagree.
1378
+ const landingCommand = ({ root, slug }) =>
1379
+ `${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`;
1380
+
1533
1381
  const orientationFields = ({ root, slug }) => ({
1534
1382
  sharedQueue: join(root, PLANS_REL, QUEUE_BASENAME),
1535
- landing: `${LANDING_FROM_MAIN} ${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`,
1383
+ landing: composeLandingValue({ rule: LANDING_FROM_MAIN, command: landingCommand({ root, slug }) }),
1536
1384
  });
1537
1385
 
1538
1386
  // Pre-mutation gate for everything the record will carry. `sharedQueue`/`landing` are derived from
@@ -1628,21 +1476,30 @@ const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report, journal
1628
1476
 
1629
1477
  // Validated BEFORE any git mutation — a bad --plan/--as never leaves a half-made worktree.
1630
1478
  const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
1479
+ // The --as ARGUMENT is checked first, before any diagnostic that would render it: a refusal is read
1480
+ // in the same terminal the cold-start prompt is. The --plan path needs no separate refusal — every
1481
+ // message below renders it through displayValue, and a hostile path reaches the derived-name guard
1482
+ // anyway, where the refusal is a runtime STOP because the offending value is a filesystem name and
1483
+ // not an argument. JSON.stringify is not the guard for either: it escapes C0 and passes C1 and
1484
+ // U+2028/U+2029 straight through.
1485
+ if (asFlag !== null && hasControlByte(asFlag)) {
1486
+ throw usageStop(`--as carries a control character, which would forge a line wherever it is rendered: ${displayValue(asFlag)}`);
1487
+ }
1631
1488
  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)}`);
1489
+ throw usageStop(`--as must be a basename ending in .md, got ${displayValue(JSON.stringify(asFlag))}`);
1633
1490
  }
1634
1491
  const srcAbs = resolve(root, planFlag);
1635
1492
  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}`);
1493
+ if (node.kind === 'absent') throw stop(`--plan: not found: ${displayValue(planFlag)}`);
1494
+ if (node.kind === 'error') throw stop(`--plan: cannot inspect ${displayValue(planFlag)} (${node.error})`);
1495
+ if (node.kind !== 'regular-file') throw stop(`--plan must be a regular non-symlink file: ${displayValue(planFlag)}`);
1639
1496
  let srcReal;
1640
1497
  try {
1641
1498
  srcReal = fs.realpath(srcAbs);
1642
1499
  } catch {
1643
- throw stop(`--plan: not found: ${planFlag}`);
1500
+ throw stop(`--plan: not found: ${displayValue(planFlag)}`);
1644
1501
  }
1645
- if (!isInside(rootReal, srcReal)) throw stop(`--plan must resolve inside the main repo: ${planFlag}`);
1502
+ if (!isInside(rootReal, srcReal)) throw stop(`--plan must resolve inside the main repo: ${displayValue(planFlag)}`);
1646
1503
  if (normalizeSlashes(dirname(srcReal)) === normalizeSlashes(join(rootReal, PLANS_REL)) && !isScratchPlanName(basename(srcReal))) {
1647
1504
  throw stop(
1648
1505
  `--plan names a bare (in-flight) plan inside MAIN's ${PLANS_REL} — the feature plan must live in the satellite ONLY, ` +
@@ -1651,10 +1508,14 @@ const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
1651
1508
  );
1652
1509
  }
1653
1510
  const name = asFlag ?? basename(srcAbs);
1654
- if (!name.endsWith('.md')) throw stop(`the seeded plan name must end in .md: ${name}`);
1511
+ if (!name.endsWith('.md')) throw stop(`the seeded plan name must end in .md: ${displayValue(name)}`);
1512
+ // The derived name too: without --as it is the source basename, which the checks above never saw.
1513
+ if (hasControlByte(name)) {
1514
+ throw stop(`the seeded plan name carries a control character, which would forge a line in the satellite's cold-start prompt: ${displayValue(name)}`);
1515
+ }
1655
1516
  if (isScratchPlanName(name)) {
1656
1517
  throw stop(
1657
- `refusing to seed a scratch-class plan name (${name}) — the worktree's review-state would read it as "no plan ` +
1518
+ `refusing to seed a scratch-class plan name (${displayValue(name)}) — the worktree's review-state would read it as "no plan ` +
1658
1519
  'in flight" and every council check would pass vacuously. Seed a bare name via --as <name>.md.',
1659
1520
  );
1660
1521
  }
@@ -1669,7 +1530,7 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report, journal = NO_JOURNAL
1669
1530
  return;
1670
1531
  }
1671
1532
  const src = readFileNoFollow(fs, srcAbs);
1672
- if (!src.bytes) throw stop(`--plan: not readable as a regular file: ${srcAbs}`);
1533
+ if (!src.bytes) throw stop(`--plan: not readable as a regular file: ${displayValue(srcAbs)}`);
1673
1534
  guardDst(fs, wtRoot, dirname(dst));
1674
1535
  fs.mkdir(dirname(dst));
1675
1536
  writeContainedFileAtomic(wtRoot, dst, String(src.bytes), fs, { stop: (m) => stop(m) });
@@ -1877,17 +1738,156 @@ const declaresNoDependencies = ({ wtRoot, fs }) => {
1877
1738
  // an earlier provision left — an install through it writes into MAIN, and the posture must never
1878
1739
  // hide that). Only then may a PROVEN dependency-free checkout short-circuit: a verdict of
1879
1740
  // "nothing to install" must not ride an install instruction.
1880
- const resolveInstallPosture = ({ wtRoot, dependencyFree, fs }) => {
1741
+ const SYMLINK_POSTURE_HEAD = 'the provisioned node_modules is a symlink into MAIN (an install through it writes into MAIN)';
1742
+ // Two DIFFERENT facts, never one wording: a link whose target was read and is not MAIN's, and a link
1743
+ // whose target could not be read at all. Claiming the second points elsewhere would state something
1744
+ // nothing established; both withhold the removal advice for the same reason.
1745
+ // Stated as the PROVEN fact and no more: the raw target does not equal the absolute path provision
1746
+ // writes. A relative target may still resolve to the same directory, and an absolute one may be a
1747
+ // provisioned link left behind by a MAIN that moved — neither is disproved here, and claiming the
1748
+ // link "points somewhere else" or "was not placed by this tool" would assert what was never checked.
1749
+ 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';
1750
+ 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';
1751
+ 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';
1752
+ 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';
1753
+
1754
+ // ONE wording for every not-ours verdict, so the report and the prompt can never describe the same
1755
+ // link differently. The foreign target is decoded FATALLY: bytes that are not text are quoted
1756
+ // nowhere, and fall to the unreadable answer rather than to a replaced string.
1757
+ const unverifiedLinkPosture = (ownership) => {
1758
+ if (ownership.verdict === 'tracked') return TRACKED_NODE_MODULES_LINK;
1759
+ if (ownership.verdict === 'lane-unproven') return `${LANE_UNPROVEN_NODE_MODULES_LINK} (${ownership.error})`;
1760
+ if (ownership.verdict === 'unreadable') return `${UNREADABLE_NODE_MODULES_LINK} (${ownership.error})`;
1761
+ const decoded = decodeTargetStrictly(ownership.target);
1762
+ return decoded === null
1763
+ ? `${UNREADABLE_NODE_MODULES_LINK} (its target is not decodable text)`
1764
+ : `${FOREIGN_NODE_MODULES_LINK}: ${decoded}`;
1765
+ };
1766
+
1767
+ // Ownership is decided on the RAW TARGET BYTES, the same evidence cleanup binds on. A decoded string
1768
+ // is not that: two different byte sequences can decode to ONE string through UTF-8 replacement, and
1769
+ // the pair that collides would authorize removing a link this tool never placed. The outcome is
1770
+ // STRUCTURED because the three answers are different facts: ours, someone else's, or unknown — and
1771
+ // an unreadable link is the last of those, never a claim about where it points.
1772
+ const readLinkTarget = (fs, path) => {
1773
+ try {
1774
+ const raw = fs.readlink(path, { encoding: 'buffer' });
1775
+ return { target: Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw)) };
1776
+ } catch (err) {
1777
+ return { error: err?.code ?? 'fs error' };
1778
+ }
1779
+ };
1780
+
1781
+ // Fatal UTF-8: a target that is not decodable text is not a target this tool will quote. Returning
1782
+ // null keeps it out of the record and the prompt entirely, rather than quoting a replaced string.
1783
+ const FATAL_UTF8_TARGET = new TextDecoder('utf-8', { fatal: true });
1784
+ const decodeTargetStrictly = (buffer) => {
1785
+ try {
1786
+ return FATAL_UTF8_TARGET.decode(buffer);
1787
+ } catch {
1788
+ return null;
1789
+ }
1790
+ };
1791
+
1792
+ // The ONE ownership question, asked the same way by every lane that acts on that link:
1793
+ // 'ours' | 'foreign' | 'tracked' | 'unreadable', plus the raw target where one was read.
1794
+ //
1795
+ // Matching bytes are HALF the proof. The cleanup ownership rule states the other half — only a
1796
+ // matching link IN THE IGNORED LANE is provision-ephemeral — and it is the half that decides whether
1797
+ // removal may be advised at all: a TRACKED link at this path is repository content, and offering to
1798
+ // delete it would advise destroying something the landing lane protects. A lane the probe cannot
1799
+ // establish is not the ignored lane either.
1800
+ const nodeModulesLinkOwnership = ({ fs, git, nmPath, wtRoot, mainRoot }) => {
1801
+ const read = readLinkTarget(fs, nmPath);
1802
+ if (read.error !== undefined) return { verdict: 'unreadable', error: read.error };
1803
+ if (Buffer.compare(read.target, Buffer.from(join(mainRoot, NODE_MODULES_REL))) !== 0) {
1804
+ return { verdict: 'foreign', target: read.target };
1805
+ }
1806
+ const lane = probeOwnedLane({ git, wtRoot, rel: NODE_MODULES_REL });
1807
+ if (lane.lane === 'ignored') return { verdict: 'ours', target: read.target };
1808
+ if (lane.lane === 'tracked') return { verdict: 'tracked', target: read.target };
1809
+ // The target WAS read here — only the lane is unsettled — so this must not borrow the wording of a
1810
+ // failed target read. Its cause is the lane probe's own: an untracked path, or a probe that could
1811
+ // not answer at all.
1812
+ return {
1813
+ verdict: 'lane-unproven',
1814
+ error: lane.detail ?? `the path is ${lane.lane}, and only an ignored one is the link provision places`,
1815
+ target: read.target,
1816
+ };
1817
+ };
1818
+ const INSTALL_RUNNABLE_DESCRIPTION = 'this checkout installs its own dependencies — the command below runs in it';
1819
+
1820
+ // Three views of ONE probe, so the record and the cold-start prompt can never disagree about this
1821
+ // checkout: `posture` is the RECORD's field, byte-for-byte what it has always been; `description` is
1822
+ // the prose half with no command in it; `command` is the runnable half, or null where none exists.
1823
+ // The split is what keeps a runnable install out of an unattributed prompt line — the posture string
1824
+ // IS a command in the ordinary case, so rendering it as prose would offer an instruction nothing
1825
+ // attributes and no command parser can see.
1826
+ const resolveInstall = ({ wtRoot, mainRoot, dependencyFree, fs, git }) => {
1881
1827
  const nmPath = join(wtRoot, 'node_modules');
1882
1828
  const nm = lstatNoFollow(fs.lstat, nmPath);
1829
+ const removal = `rm ${shellQuoteArg(nmPath)}`;
1883
1830
  if (nm !== null && nm.isSymbolicLink()) {
1831
+ // A symlink is not proof of THIS tool's link. The provisioned one points at MAIN's
1832
+ // node_modules; anything else is a node the session (or a later hand) put there, and claiming
1833
+ // "a symlink into MAIN" about it would state a live fact nothing checked — and then advise
1834
+ // removing something this tool never placed. Ownership is decided by the raw target bytes,
1835
+ // the same evidence cleanup binds on.
1836
+ const ownership = nodeModulesLinkOwnership({ fs, git, nmPath, wtRoot, mainRoot });
1837
+ if (ownership.verdict !== 'ours') {
1838
+ // The target reaches the record's own value guard and the prompt's, and each refuses a hostile
1839
+ // one by NAME — so it is decoded FATALLY here: a lossy decode would fold undecodable bytes to
1840
+ // U+FFFD and hand both guards a sanitized string, a silent pass where a typed STOP was
1841
+ // promised. Bytes that are not text at all get the same treatment as an unreadable link:
1842
+ // nothing is claimed about them. displayValue belongs in diagnostics only.
1843
+ const unverified = unverifiedLinkPosture(ownership);
1844
+ return { posture: unverified, description: unverified, command: null };
1845
+ }
1884
1846
  const advice = resolveInstallAdvice({ wtRoot, fs });
1885
1847
  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}`;
1848
+ // The REMOVAL is runnable even when no install command is derivable, so it rides the attributed
1849
+ // line either way and never sits loose inside prose.
1850
+ return {
1851
+ posture: `${SYMLINK_POSTURE_HEAD} — for isolation remove it first: ${removal}${separator}${advice.instruction}`,
1852
+ description: advice.command === null
1853
+ ? `${SYMLINK_POSTURE_HEAD} — for isolation remove it first with the command below; then ${NEUTRAL_INSTALL_ADVICE}`
1854
+ : `${SYMLINK_POSTURE_HEAD} — for isolation remove it first and install; the command below does both`,
1855
+ command: advice.command === null ? removal : `${removal} && ${advice.command}`,
1856
+ };
1887
1857
  }
1888
- if (dependencyFree) return NO_DEPENDENCIES_POSTURE;
1889
- return resolveInstallAdvice({ wtRoot, fs }).instruction;
1890
- };
1858
+ if (dependencyFree) {
1859
+ return { posture: NO_DEPENDENCIES_POSTURE, description: NO_DEPENDENCIES_POSTURE, command: null };
1860
+ }
1861
+ const advice = resolveInstallAdvice({ wtRoot, fs });
1862
+ return advice.command === null
1863
+ ? { posture: advice.instruction, description: advice.instruction, command: null }
1864
+ : { posture: advice.instruction, description: INSTALL_RUNNABLE_DESCRIPTION, command: advice.command };
1865
+ };
1866
+
1867
+
1868
+ // The satellite's cold-start prompt, composed from LIVE facts at both print sites (D16): provision
1869
+ // ends its report with it, and `prompt <slug>` re-prints it later from MAIN. The record is passed in
1870
+ // only so a value FROZEN at provision time can be named where it no longer matches.
1871
+ //
1872
+ // It PROBES NOTHING. The satellite-derived facts — the seeded plan and the install posture — are
1873
+ // arguments, because provision has already established both by the time it composes and a second
1874
+ // read there would be a fresh failure window after the work is done; `prompt` resolves them itself,
1875
+ // where a failure is the whole outcome of the run.
1876
+ const composeSatellitePromptFor = ({ root, wtRoot, slug, branch, record, plan, install }) => composeSatellitePrompt({
1877
+ slug,
1878
+ branch,
1879
+ worktreePath: wtRoot,
1880
+ plan,
1881
+ live: {
1882
+ sharedQueue: orientationFields({ root, slug }).sharedQueue,
1883
+ landingRule: LANDING_FROM_MAIN,
1884
+ landingCommand: landingCommand({ root, slug }),
1885
+ installPosture: install.posture,
1886
+ installDescription: install.description,
1887
+ installCommand: install.command,
1888
+ },
1889
+ record,
1890
+ });
1891
1891
 
1892
1892
  const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report, journal = NO_JOURNAL }) => {
1893
1893
  // The lane places ONLY a symlink, so the kind gate admits only a symlink at this path: a
@@ -1898,6 +1898,16 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyF
1898
1898
  const dst = join(wtRoot, NODE_MODULES_REL);
1899
1899
  const existing = lstatNoFollow(fs.lstat, dst);
1900
1900
  if (existing !== null && existing.isSymbolicLink()) {
1901
+ // The unlink-first advice belongs to OUR link and to no other: for a link this tool never
1902
+ // placed it would offer to delete a node whose target it has not established, and the
1903
+ // cold-start prompt would then contradict the report in the same breath. Same ownership
1904
+ // question, same raw-bytes evidence.
1905
+ const ownership = nodeModulesLinkOwnership({ fs, git, nmPath: dst, wtRoot, mainRoot: root });
1906
+ if (ownership.verdict !== 'ours') {
1907
+ journalLink('kept');
1908
+ report.push(` node_modules: ${displayValue(unverifiedLinkPosture(ownership))}`);
1909
+ return 'install-printed-unverified-link';
1910
+ }
1901
1911
  // isolation only exists BEFORE the link: an install through it would write into MAIN
1902
1912
  const separator = install.command === null ? ' — ' : ' && ';
1903
1913
  journalLink('kept');
@@ -2254,7 +2264,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2254
2264
  const inFlight = plansInFlight(targetPath, fs.readdir);
2255
2265
  if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
2256
2266
  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`,
2267
+ `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
2268
  );
2259
2269
  }
2260
2270
 
@@ -2278,6 +2288,33 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2278
2288
  }
2279
2289
  }
2280
2290
 
2291
+ // ONE probe, three views: the record takes the posture, the prompt takes the prose and the
2292
+ // runnable half. A second probe here could disagree with what the record is about to state.
2293
+ const install = resolveInstall({ wtRoot: targetPath, mainRoot: root, dependencyFree, fs, git });
2294
+ const fields = {
2295
+ slug,
2296
+ branch,
2297
+ includes: includesRecorded,
2298
+ nodeModules: nodeModulesMode,
2299
+ vscode: vscodeMode,
2300
+ install: install.posture,
2301
+ ...orientationFields({ root, slug }),
2302
+ };
2303
+ // Composed BEFORE the record is written and before ANY output: the values are already established
2304
+ // (the seeded plan passed the EXACTLY-ONE check above, the install posture is the one going into
2305
+ // the record), so a composition failure lands where every other late provision failure lands —
2306
+ // with the record bytes untouched and no success line printed — instead of contradicting a success
2307
+ // message it would otherwise follow.
2308
+ const prompt = composeSatellitePromptFor({
2309
+ root,
2310
+ wtRoot: targetPath,
2311
+ slug,
2312
+ branch,
2313
+ record: fields,
2314
+ plan: seed.name,
2315
+ install,
2316
+ });
2317
+
2281
2318
  // The record refresh runs LAST, after the in-flight check and the verify, in BOTH lanes —
2282
2319
  // the record attests only a VERIFIED provision; a failed run leaves the prior record bytes
2283
2320
  // (the stub on a failed first provision). On resume the generic kept-worktree NOTE does not
@@ -2289,15 +2326,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2289
2326
  slug,
2290
2327
  branch,
2291
2328
  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
- },
2329
+ fields,
2301
2330
  fs,
2302
2331
  report,
2303
2332
  });
@@ -2312,6 +2341,10 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
2312
2341
  for (const line of report) log(line);
2313
2342
  log(`[worktrees] provisioned ${slug} at ${targetPath} (branch ${branch}, base ${base})`);
2314
2343
  log(`open it: code -n ${shellQuoteArg(targetPath)}`);
2344
+ // The report ENDS with the satellite's cold-start prompt: the facts a fresh session in that
2345
+ // checkout cannot derive are worth nothing if they are only re-derivable on request.
2346
+ log('');
2347
+ log(prompt);
2315
2348
  return EXIT.ok;
2316
2349
  };
2317
2350
 
@@ -2362,6 +2395,30 @@ export const runList = ({ cwd, git, deps, log }) => {
2362
2395
  return EXIT.ok;
2363
2396
  };
2364
2397
 
2398
+ // ── prompt ─────────────────────────────────────────────────────────────────────────────
2399
+
2400
+ // Read-only: it resolves the satellite, proves the handoff identity there, and prints. Nothing is
2401
+ // written, and MAIN is where it must run — the orientation it composes is MAIN's, so the
2402
+ // linked-worktree refusal in resolveRoots is the guard that keeps it honest.
2403
+ export const runPrompt = ({ argvSlug, cwd, git, deps, log }) => {
2404
+ const fs = fsOf(deps);
2405
+ const slug = validateSlug(argvSlug);
2406
+ const { root } = resolveRoots(cwd, git);
2407
+ const entry = findSatelliteEntry({ root, slug, branch: null, git, fs });
2408
+ const identity = readSatelliteIdentity({ entry, slug, fs });
2409
+ const wtRoot = entry.path;
2410
+ log(composeSatellitePromptFor({
2411
+ root,
2412
+ wtRoot,
2413
+ slug,
2414
+ branch: identity.branch,
2415
+ record: identity.record,
2416
+ plan: resolveSeededPlan({ wtRoot, readdir: fs.readdir }),
2417
+ install: resolveInstall({ wtRoot, mainRoot: root, dependencyFree: declaresNoDependencies({ wtRoot, fs }), fs, git }),
2418
+ }));
2419
+ return EXIT.ok;
2420
+ };
2421
+
2365
2422
  // ── land + cleanup ────────────────────────────────────────────────────────────────────
2366
2423
 
2367
2424
  const nulFields = (text) => String(text).split('\0').filter((field) => field !== '');
@@ -2433,57 +2490,6 @@ const withPrepareLock = ({ commonDir, fs, now }, action) => {
2433
2490
  return result;
2434
2491
  };
2435
2492
 
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
2493
  const changedPaths = (git, args, cwd, label) =>
2488
2494
  nulFields(gitRead(git, [...args, '-z', '--', ...TRANSFER_EXCLUSIONS], cwd, label).stdout);
2489
2495
 
@@ -2689,12 +2695,15 @@ const runSyncAdapter = ({ root, mainHead, transferPaths, git, fs, deps, report }
2689
2695
  return delta;
2690
2696
  };
2691
2697
 
2692
- const recordPreparedTree = ({ identity, slug, entry, prepared, fs }) => {
2698
+ // prepared-head rides the SAME record refresh as prepared-tree (D8): MAIN's HEAD at prepare time
2699
+ // is what lets the return rung tell a still-pending prepared set from an already-committed one —
2700
+ // a clean post-commit index reproduces the committed tree, so the tree OID alone cannot.
2701
+ const recordPreparedTree = ({ identity, slug, entry, prepared, preparedHead, fs }) => {
2693
2702
  writeHandoffRecord({
2694
2703
  wtRoot: entry.path,
2695
2704
  slug,
2696
2705
  branch: identity.branch,
2697
- fields: { ...identity.record, prepared },
2706
+ fields: { ...identity.record, prepared, preparedHead },
2698
2707
  fs,
2699
2708
  report: [],
2700
2709
  });
@@ -2714,12 +2723,12 @@ const dirtyMainStop = ({ root, git, record, porcelain }) => {
2714
2723
  const hasTrackedUnstaged = trackedEntries.some((entry) => entry.code[1] !== ' ');
2715
2724
  const mayReset = converged && !hasTrackedUnstaged;
2716
2725
  const classification = converged
2717
- ? `converged re-run: current staged write-tree matches the previous prepare's recorded OID ${record.prepared}`
2726
+ ? `converged re-run: current staged write-tree matches the previous prepare's recorded OID ${displayValue(record.prepared)}`
2718
2727
  : record.prepared === null
2719
2728
  ? 'foreign staged work: no previous prepare OID is recorded'
2720
2729
  : 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}`;
2730
+ ? `foreign staged work: the index has no staged delta against HEAD, although its write-tree matches the recorded OID ${displayValue(record.prepared)}`
2731
+ : `foreign staged work: current staged write-tree differs from the previous prepare's recorded OID ${displayValue(record.prepared)}`;
2723
2732
  const leftoversReport = leftovers.length === 0
2724
2733
  ? []
2725
2734
  : mayReset
@@ -2788,7 +2797,7 @@ export const runLand = ({ argvSlug, flags, cwd, git, deps, log }) => {
2788
2797
  const syncDelta = runSyncAdapter({ root, mainHead, transferPaths, git, fs, deps, report });
2789
2798
  const preparedTree = gitRead(git, ['write-tree'], root, 'cannot write the prepared main tree').stdout.trim();
2790
2799
  try {
2791
- recordPreparedTree({ identity, slug, entry, prepared: preparedTree, fs });
2800
+ recordPreparedTree({ identity, slug, entry, prepared: preparedTree, preparedHead: mainHead, fs });
2792
2801
  } catch (error) {
2793
2802
  throw withRollbackFailures(error, rollbackMain({ root, mainHead, git, fs }));
2794
2803
  }
@@ -2876,7 +2885,7 @@ const registryRoots = () => {
2876
2885
  const safeRecordedPath = (path) => {
2877
2886
  const normalized = normalizeSlashes(String(path)).replace(/^\.\//, '').replace(/\/$/, '');
2878
2887
  if (!normalized || isAbsolute(normalized) || normalized.split('/').includes('..')) {
2879
- throw stop(`handoff record carries an unsafe provision path: ${path}`);
2888
+ throw stop(`handoff record carries an unsafe provision path: ${displayValue(path)}`);
2880
2889
  }
2881
2890
  return normalized;
2882
2891
  };
@@ -3196,12 +3205,13 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
3196
3205
  export const parseArgs = (argv) => {
3197
3206
  const [sub, ...rest] = argv;
3198
3207
  if (sub === undefined || sub === '--help' || sub === '-h') return { sub: 'help' };
3199
- if (!['provision', 'list', 'land', 'cleanup'].includes(sub)) {
3208
+ if (!['provision', 'list', 'prompt', 'land', 'cleanup'].includes(sub)) {
3200
3209
  throw usageStop(`unknown subcommand ${JSON.stringify(sub)}\n${USAGE}`);
3201
3210
  }
3202
3211
  const SUB_FLAGS = {
3203
3212
  provision: ['--plan', '--as', '--dir', '--branch', '--include', '--install', '--resume'],
3204
3213
  list: [],
3214
+ prompt: [],
3205
3215
  land: ['--prepare'],
3206
3216
  cleanup: ['--branch', '--abandon'],
3207
3217
  };
@@ -3245,6 +3255,7 @@ export const runCli = (argv, deps = {}) => {
3245
3255
  return runProvision({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
3246
3256
  }
3247
3257
  if (parsed.sub === 'list') return runList({ cwd, git, deps, log });
3258
+ if (parsed.sub === 'prompt') return runPrompt({ argvSlug: parsed.slug, cwd, git, deps, log });
3248
3259
  if (parsed.sub === 'land') {
3249
3260
  return runLand({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
3250
3261
  }