@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
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // dispatch.mjs — the delegation ENGINE (delegation Plan 1, Phase 3; the writer verbs are Plan 2,
3
3
  // Phase 2 and the waiter Plan 2, Phase 4): the ONE human-facing surface over the record vocabulary
4
- // (dispatch-record.mjs) and the ledger (dispatch-store.mjs). Nine verbs:
4
+ // (dispatch-record.mjs) and the ledger (dispatch-store.mjs). Eleven verbs:
5
5
  //
6
- // check <dispatch-file> the D8 sub-task contract header, FORM-only, exit 0/1
6
+ // check <dispatch-file> the D8 sub-task contract header, FORM-only, exit 0/1 (+ the advisory footer)
7
+ // advise --step-class which vehicle carries this step class on THIS host — advice, never a gate
7
8
  // register the wave's PRE-REGISTRATION record (classes, pairing key, thresholds)
8
9
  // observe one OBSERVATION record (the solo baseline / a self-reported datum)
9
10
  // open the DISPATCH record — every mint-time field COPIED from the header
@@ -11,6 +12,7 @@
11
12
  // return the RETURN record — the wrapper's exec receipt ABSORBED through this door
12
13
  // fold the FOLD record — the integration re-confirmation
13
14
  // degrade the DEGRADE record — the recorded no-fold closure
15
+ // handoff-return the worktree-stream return rung — deliver, prove, then count
14
16
  // aggregate [--wave] the L0 deterministic report over ONE wave
15
17
  //
16
18
  // Why an engine at all: the funded metric — how much leverage a delegated sub-task actually buys —
@@ -58,31 +60,39 @@
58
60
  //
59
61
  // Writer: appends to the delegation ledger through the store's lock-serialized append (the store's
60
62
  // preflight is the single legality door — this module adds NO second validator). Never commits,
61
- // never runs a subscription CLI, spawns nothing but git READS. Dependency-free, Node >= 22. No side
62
- // effects on import (the isDirectRun idiom).
63
+ // never runs a subscription CLI, spawns nothing but git READS with ONE stated exception:
64
+ // `handoff-return` attests MAIN's index with `git write-tree`, which may write a tree OBJECT into
65
+ // the odb and moves no ref (the same probe `land --prepare` itself uses). Dependency-free,
66
+ // Node >= 22. No side effects on import (the isDirectRun idiom).
63
67
 
64
- import { readFileSync, openSync, closeSync, realpathSync, readlinkSync, constants as fsConstants } from 'node:fs';
65
- import { resolve, isAbsolute, sep, dirname, basename, join } from 'node:path';
68
+ import { readFileSync, realpathSync, readlinkSync } from 'node:fs';
69
+ import { resolve, isAbsolute, dirname, basename, join } from 'node:path';
66
70
  import { fileURLToPath } from 'node:url';
67
71
  import { createHash } from 'node:crypto';
68
72
  import {
69
73
  DELEGATION_SCHEMA_VERSION, STEP_CLASSES, OBSERVATION_PROVENANCE, RETURN_OUTCOMES,
70
74
  SESSION_ID_NULLABLE_OUTCOMES, checkDispatchContractForm, checkDispatchMintConsistency,
71
75
  contractDigest, canonicalDelegationDigest, computeNumerator, evaluateMetricEligibility,
72
- evaluateObservationEligibility,
73
76
  } from './dispatch-record.mjs';
74
77
  import {
75
78
  appendDelegationRecord, readDelegationStore, resolveDelegationStorePath, delegationThreadState,
76
79
  auditDelegationStoreSemantics, uncommittedStateFingerprint, DELEGATION_STORE_BASENAME,
77
80
  } from './dispatch-store.mjs';
81
+ import {
82
+ renderAdvisorBlock, renderSelectionNote, advisorDeps, advisorRow, ADVISOR_STEP_CLASSES,
83
+ ADVISOR_PROBE_POSTURE,
84
+ } from './dispatch-advisor.mjs';
78
85
  import { execReceiptBasename, execReportBasename, parseExecReceipt } from './exec-receipt.mjs';
79
86
  import {
80
87
  enumerateReturnedObjects, computeReturnedDiff, assembleIntegrationBundle,
81
88
  } from './exec-producer.mjs';
82
89
  import { gitBuf, isTreeClean } from './core-evidence.mjs';
83
- import { lstatNoFollowRead, describeNonRegular, readRegularFileNoFollow, readFileBytesNoFollow } from './fs-read-nofollow.mjs';
90
+ import { lstatNoFollowRead, readRegularFileNoFollow, readFileBytesNoFollow } from './fs-read-nofollow.mjs';
84
91
  import { gitLine } from './flow-store-read.mjs';
85
- import { lexicalRepoRelative } from './repo-lex.mjs';
92
+ import {
93
+ resolveRepoRoot, measureScope, ratio, formatRatio, buildObservationRecord,
94
+ } from './observation-builder.mjs';
95
+ import { handoffReturn, HANDOFF_SLUG_RE } from './worktree-handoff-return.mjs';
86
96
 
87
97
  const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
88
98
 
@@ -195,11 +205,6 @@ const OBSERVE_REPEATABLE = new Set(['--scope']);
195
205
  // would record a contract the computation does not follow — refused at both ends.
196
206
  export const IMPLEMENTED_PAIRING_KEYS = Object.freeze(['stepClass']);
197
207
 
198
- // The solo baseline counts each scope object's POST-IMAGE — the bytes on disk after the construction
199
- // — which is exactly the `new` numerator rule (D6). No exec diff kind enumerates ranges, so a solo
200
- // observation can never claim a partial object.
201
- const SOLO_COMPONENT_KIND = 'new';
202
-
203
208
  // Acceptance aggregates the git-provable domain only (D6/R2).
204
209
  const ACCEPTANCE_PROVENANCE = 'wrapper-git';
205
210
 
@@ -285,9 +290,65 @@ const asNumber = (flag, raw) => {
285
290
 
286
291
  const refusal = (verb, reason) => ({ code: 1, stdout: '', stderr: `dispatch ${verb}: ${reason}` });
287
292
 
293
+ // ── advise: the vehicle-routing advisory, at its two points of use ────────────────────────────────
294
+ // It refuses nothing and decides nothing (D1). The ledger is read through the SAME single door every
295
+ // deriving verb uses, and its OUTCOME is handed to the advisor — an unreadable store degrades the
296
+ // history line rather than suppressing the advice or moving an exit code.
297
+
298
+ export const ADVISE_FLAG_FIELDS = Object.freeze({ '--step-class': 'stepClass' });
299
+
300
+ // The advisory probe may never own an exit code, so neither of its two throwing inputs escapes it.
301
+ // The store path resolution REFUSES a relative override and one ending in a separator by throwing
302
+ // (dispatch-store.mjs:61-71), and the top-level resolution spawns git: an exception from either would
303
+ // delete a form-valid `check`'s verdict line and turn its exit 0 into a refusal — which is exactly
304
+ // the gate D1 says this surface never becomes. Caught here, the store's own words still travel, as
305
+ // the history line, which is already where an unreadable ledger speaks.
306
+ const ledgerOutcome = (cwd, env) => {
307
+ try {
308
+ return readLegalLedger(cwd, env);
309
+ } catch (err) {
310
+ return { ok: false, reason: err?.message ?? String(err) };
311
+ }
312
+ };
313
+
314
+ // The cheap vehicles live at the REPOSITORY top-level (.claude/agents/), never at the caller's cwd —
315
+ // a run from a subdirectory would otherwise report a placed vehicle as absent. Only the VEHICLE probe
316
+ // is re-anchored: the ledger keeps the original cwd, because its own resolution is git-common-dir
317
+ // based and already answers the same from anywhere inside the tree.
318
+ //
319
+ // The result is a PAIR, not a path. `resolveRepoRoot` answers null both for "not a work tree" and for
320
+ // "the git probe did not answer", and a throw is a third way to learn nothing — collapsing all three
321
+ // into the caller's cwd made an unlocatable vehicle print as "not placed", which is a fact this tool
322
+ // does not have. Unanchored, the agent lane says `unknown` instead.
323
+ // The resolver is a SEAM (ctx.repoRoot) rather than a hard call, because "the probe threw" is a lane
324
+ // with its own printed answer and a lane nothing can reach by arranging a directory: the throw comes
325
+ // from a git spawn or a realpath the caller cannot make fail on demand. A seam makes the branch
326
+ // exercisable in-process, which is the only place coverage can see it (D14).
327
+ const vehicleAnchor = (cwd, repoRoot) => {
328
+ try {
329
+ const root = repoRoot(cwd);
330
+ return root === null ? { cwd, anchored: false } : { cwd: root, anchored: true };
331
+ } catch {
332
+ return { cwd, anchored: false };
333
+ }
334
+ };
335
+
336
+ const advisoryBlock = ({ cwd, env, stepClass, repoRoot }) =>
337
+ renderAdvisorBlock({ stepClass, ledger: ledgerOutcome(cwd, env), deps: advisorDeps(vehicleAnchor(cwd, repoRoot)) });
338
+
339
+ const runAdvise = ({ argv, baseCwd, env, repoRoot }) => {
340
+ const { values, operands, cwd } = scan(argv, ADVISE_FLAG_FIELDS, baseCwd);
341
+ refuseOperands('advise', operands);
342
+ const stepClass = need(values, '--step-class');
343
+ if (advisorRow(stepClass) === undefined) {
344
+ throw usageFail(`--step-class must be one of the D9 step classes ${ADVISOR_STEP_CLASSES.join(' | ')} (got "${stepClass}")`);
345
+ }
346
+ return { code: 0, stdout: advisoryBlock({ cwd, env, stepClass, repoRoot }), stderr: '' };
347
+ };
348
+
288
349
  // ── check: the D8 contract header, FORM only ──────────────────────────────────────────────────────
289
350
 
290
- const runCheck = ({ argv, baseCwd }) => {
351
+ const runCheck = ({ argv, baseCwd, env, repoRoot }) => {
291
352
  const { operands, cwd } = scan(argv, {}, baseCwd);
292
353
  if (operands.length > 1) throw usageFail(`unknown argument: ${operands[1]}`);
293
354
  const file = operands[0];
@@ -302,9 +363,17 @@ const runCheck = ({ argv, baseCwd }) => {
302
363
  const form = checkDispatchContractForm(text);
303
364
  if (!form.ok) return { code: 1, stdout: `dispatch check: FORM VIOLATION — ${form.reason}`, stderr: '' };
304
365
  const c = form.contract;
366
+ // The advisory footer prints ONLY here, under a form-valid contract, so it can never mask a
367
+ // refusal: the exit code and the FIRST line above are identical whatever the advisor concludes.
368
+ const advisory = [advisoryBlock({ cwd, env, stepClass: c.stepClass, repoRoot }), renderSelectionNote(c)]
369
+ .filter((line) => line !== null && line !== undefined);
305
370
  return {
306
371
  code: 0,
307
- stdout: `dispatch check: FORM OK — nonce "${c.nonce}", step class "${c.stepClass}", vehicle ${c.vehicle.requested} → ${c.vehicle.selected}, deadline ${c.deadlineS}s, retry ${c.retry.index}/${c.retry.cap}\n (${DISPATCH_CONTRACT})`,
372
+ stdout: [
373
+ `dispatch check: FORM OK — nonce "${c.nonce}", step class "${c.stepClass}", vehicle ${c.vehicle.requested} → ${c.vehicle.selected}, deadline ${c.deadlineS}s, retry ${c.retry.index}/${c.retry.cap}`,
374
+ ` (${DISPATCH_CONTRACT})`,
375
+ ...advisory,
376
+ ].join('\n'),
308
377
  stderr: '',
309
378
  };
310
379
  };
@@ -342,84 +411,13 @@ const runRegister = ({ baseCwd, env, argv, now }) => {
342
411
  };
343
412
 
344
413
  // ── observe: one hand-recorded observation ────────────────────────────────────────────────────────
345
-
346
- const ratio = (value) => value.toFixed(3);
347
-
348
- // L is printed ONLY where the metric is eligible: an ineligible metric has a NAMED reason and no
349
- // ratio at all, and printing a number beside the name is how a silent zero gets read as a measurement.
350
- const formatRatio = (metric) => (metric.eligible
351
- ? `L = ${ratio(metric.numeratorBytes / metric.denominatorBytes)} (${metric.numeratorBytes} B / ${metric.denominatorBytes} B)`
352
- : `L = n/a — INELIGIBLE (${metric.ineligibleReason})`);
414
+ // The scope measurement and the record construction live in observation-builder.mjs, so this verb
415
+ // and the handoff-return rung build the IDENTICAL record through ONE path (the rung cannot import
416
+ // this module back — dispatch.mjs imports the rung for its verb, and the tools graph is pinned
417
+ // acyclic).
353
418
 
354
419
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
355
420
 
356
- // The scope's anchor is the git TOP-LEVEL, never the caller's cwd: a recorded scope must name the
357
- // same objects whoever runs the tool from wherever. `null` outside a work tree — a repo-relative
358
- // domain with no repository has nothing to be relative TO, and falling back to cwd would be a
359
- // second, incompatible semantics for the same field.
360
- const resolveRepoRoot = (cwd) => {
361
- if (gitLine(['rev-parse', '--is-inside-work-tree'], cwd) !== 'true') return null;
362
- const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
363
- return top === null ? null : realpathSync(top);
364
- };
365
-
366
- // The read is no-follow on the LEAF (a symlinked leaf is already refused by name above; O_NOFOLLOW
367
- // makes a swap between the classification and the read fail loudly rather than counting another
368
- // object's bytes). Honest limit: classify-then-read is not race-free, and it is not meant to be —
369
- // the scope is the orchestrator's OWN work tree and the result is a MAGNITUDE, never a store
370
- // identity, so a pathname race costs a wrong byte count, not a forged record.
371
- const readObjectBytes = (path) => {
372
- const fd = openSync(path, (fsConstants.O_RDONLY ?? 0) | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
373
- try {
374
- return readFileSync(fd);
375
- } finally {
376
- closeSync(fd);
377
- }
378
- };
379
-
380
- // One scope object → one numerator entry. Refuses by NAME on anything it cannot count honestly: a
381
- // path escaping the repo LEXICALLY, an absent path, a non-regular path (a symlinked leaf included —
382
- // following one would count another object's bytes under this name), and a path whose REAL location
383
- // is outside the repository. The last one is the case the lexical rule alone cannot see: it rejects
384
- // `../x` while accepting `link/x`, where `link` is an ancestor symlink pointing out of the tree.
385
- // The identity is the CANONICAL repo-relative path taken from the verified real path — not a content
386
- // hash. The solo domain has no rename lineage for a content id to protect, and a content id would
387
- // let one object read between two measurements look like TWO objects instead of refusing as the
388
- // producer contradiction it is ("one identity, one size"). Two equal-byte files at different paths
389
- // are two objects and count twice; one path reached twice (a second listing, an in-repo ancestor
390
- // symlink) is one object and counts once.
391
- const measureObject = (root, rel) => {
392
- const lexical = lexicalRepoRelative(rel);
393
- if (!lexical.ok) return { ok: false, reason: `scope path "${rel}": ${lexical.reason}` };
394
- const path = resolve(root, rel);
395
- const stat = lstatNoFollowRead(path);
396
- if (stat === null) return { ok: false, reason: `scope path "${rel}" does not exist — an observation counts objects that are actually there (fail closed)` };
397
- if (!stat.isFile()) return { ok: false, reason: `scope path "${rel}" is a ${describeNonRegular(stat)}, not a regular file — the scope names repository objects (fail closed)` };
398
- const real = realpathSync(path);
399
- if (!real.startsWith(`${root}${sep}`)) {
400
- return { ok: false, reason: `scope path "${rel}" resolves to ${real}, which leaves the repository at ${root} — an ancestor symlink is not a way out of the scope domain (fail closed)` };
401
- }
402
- const canonical = real.slice(root.length + 1);
403
- const bytes = readObjectBytes(path);
404
- return { ok: true, entry: { kind: SOLO_COMPONENT_KIND, path: canonical, objectId: canonical, postImageBytes: bytes.length } };
405
- };
406
-
407
- // One repo-relative path per `--scope` occurrence, in the order given. The measured CANONICAL paths
408
- // become the record's `scope` as a canonical JSON array, so what was measured and what is written
409
- // down are the same statement — and a path carrying a space says so unambiguously.
410
- const measureScope = (root, paths) => {
411
- const entries = [];
412
- for (const rel of paths) {
413
- const measured = measureObject(root, rel);
414
- if (!measured.ok) return measured;
415
- entries.push(measured.entry);
416
- }
417
- const numerator = computeNumerator(entries);
418
- return numerator.ok
419
- ? { ...numerator, scope: JSON.stringify(entries.map((e) => e.path)) }
420
- : { ok: false, reason: numerator.reason };
421
- };
422
-
423
421
  const runObserve = ({ baseCwd, env, argv, now }) => {
424
422
  const { values, operands, cwd } = scan(argv, OBSERVE_FLAG_FIELDS, baseCwd, { repeatable: OBSERVE_REPEATABLE });
425
423
  refuseOperands('observe', operands);
@@ -444,25 +442,16 @@ const runObserve = ({ baseCwd, env, argv, now }) => {
444
442
  const denominatorBytes = solo
445
443
  ? measured.numeratorBytes
446
444
  : asInteger('--denominator-bytes', need(values, '--denominator-bytes'));
447
- const eligibility = evaluateObservationEligibility({ numeratorBytes: measured.numeratorBytes, denominatorBytes });
448
- const record = {
449
- schema: DELEGATION_SCHEMA_VERSION,
450
- kind: 'observation',
445
+ const record = buildObservationRecord({
451
446
  waveId: need(values, '--wave'),
452
447
  stepClass: need(values, '--step-class'),
453
- scope: measured.scope,
454
- metric: {
455
- numeratorBytes: measured.numeratorBytes,
456
- denominatorBytes,
457
- components: measured.components,
458
- provenance,
459
- eligible: eligibility.eligible,
460
- ineligibleReason: eligibility.ineligibleReason,
461
- },
448
+ measured,
449
+ provenance,
450
+ denominatorBytes,
462
451
  planId: need(values, '--plan'),
463
452
  phase: asInteger('--phase', need(values, '--phase')),
464
453
  timestamp: now(),
465
- };
454
+ });
466
455
  const { writtenPath } = appendDelegationRecord({ cwd, record, env });
467
456
  // DISTINCT objects, not scope entries: the numerator dedups on the canonical path, so counting
468
457
  // entries would report two objects where one was counted — the echo must agree with the number.
@@ -1348,6 +1337,40 @@ const runDegrade = ({ baseCwd, env, argv, now }) => {
1348
1337
  };
1349
1338
  };
1350
1339
 
1340
+ // ── handoff-return: the worktree-stream return rung ───────────────────────────────────────────────
1341
+ // The rung itself lives in worktree-handoff-return.mjs (it locates the satellite through the shared
1342
+ // locator leaf, so the worktrees tool never enters this CLI's import closure); this wrapper owns
1343
+ // only the flag surface, exactly like every other verb.
1344
+
1345
+ export const HANDOFF_RETURN_FLAG_FIELDS = Object.freeze({
1346
+ '--wave': 'waveId',
1347
+ '--plan': 'planId',
1348
+ '--phase': 'phase',
1349
+ });
1350
+ export const HANDOFF_RETURN_INPUT_FLAGS = Object.freeze({
1351
+ '--slug': 'the satellite slug the handoff identity is resolved from',
1352
+ });
1353
+
1354
+ const runHandoffReturn = ({ baseCwd, env, argv, now }) => {
1355
+ const { values, operands, cwd } = scan(argv, { ...HANDOFF_RETURN_FLAG_FIELDS, ...HANDOFF_RETURN_INPUT_FLAGS }, baseCwd);
1356
+ refuseOperands('handoff-return', operands);
1357
+ const slug = need(values, '--slug');
1358
+ // Refused as USAGE before any probe: the locator's own refusals interpolate the slug into a
1359
+ // terminal message, and the worktrees grammar is what keeps that echo safe.
1360
+ if (!HANDOFF_SLUG_RE.test(slug)) {
1361
+ throw usageFail(`--slug must match the worktrees slug grammar (lowercase letters, digits, hyphens, max 64 chars, letter/digit first; got ${JSON.stringify(slug)})`);
1362
+ }
1363
+ return handoffReturn({
1364
+ cwd,
1365
+ env,
1366
+ now,
1367
+ slug,
1368
+ waveId: need(values, '--wave'),
1369
+ planId: need(values, '--plan'),
1370
+ phase: asInteger('--phase', need(values, '--phase')),
1371
+ });
1372
+ };
1373
+
1351
1374
  // ── aggregate: the L0 report over ONE wave ────────────────────────────────────────────────────────
1352
1375
 
1353
1376
  const WAVE_BEARING_KINDS = ['pre-registration', 'dispatch', 'observation', 'degrade'];
@@ -1542,6 +1565,7 @@ record, the arrival waiter, and the L0 acceptance report.
1542
1565
 
1543
1566
  Usage:
1544
1567
  node dispatch.mjs check <dispatch-file> [--cwd <dir>]
1568
+ node dispatch.mjs advise --step-class <c> [--cwd <dir>]
1545
1569
  node dispatch.mjs register --wave <id> --step-classes <c[,c...]>
1546
1570
  --pairing-key ${IMPLEMENTED_PAIRING_KEYS.join('|')}
1547
1571
  --min-per-class <n> --mean-l-threshold <x>
@@ -1558,10 +1582,25 @@ Usage:
1558
1582
  node dispatch.mjs fold --nonce <n> --verdict <text> [--cwd <dir>]
1559
1583
  node dispatch.mjs degrade --wave <id> --step-class <c> --rationale <text>
1560
1584
  [--nonce <n>] [--cwd <dir>]
1585
+ node dispatch.mjs handoff-return --slug <s> --wave <id> --plan <id> --phase <n> [--cwd <dir>]
1561
1586
  node dispatch.mjs aggregate [--wave <id>] [--cwd <dir>]
1562
1587
 
1563
1588
  check reads the ONE \`\`\`aw-dispatch-contract fenced block in the dispatch file and validates its
1564
- FORM: ${DISPATCH_CONTRACT}. Exit 0 form-valid; 1 names the FIRST violated field.
1589
+ FORM: ${DISPATCH_CONTRACT}. Exit 0 form-valid; 1 names the FIRST violated field. On a form-VALID
1590
+ contract it then prints the advisory block below, plus a divergence NOTE when the contract's SELECTED
1591
+ vehicle is not the advised one — the footer prints only over a valid form, so it can never mask a
1592
+ refusal, and the exit code and the FIRST line never move with it.
1593
+
1594
+ advise answers "which vehicle carries this step class on THIS host, and what has the ledger recorded
1595
+ for it" — and DECIDES nothing: it refuses no dispatch and gates no verb. Posture: ${ADVISOR_PROBE_POSTURE}.
1596
+ Host capability is read from the filesystem (the execute backend from the bridge install, the cheap
1597
+ vehicles from the presence of .claude/agents/<name>.md at the REPOSITORY top-level — where that root
1598
+ is not resolved the lane answers "unknown" rather than claiming a vehicle is unplaced);
1599
+ doc-research is HOST-LOCAL and never claimed portable, and the
1600
+ harness's own subagent lane carries no availability verdict at all. The recorded history is the
1601
+ ledger's own thread walk over the four states folded | failure-terminal | degrade-closed | open, with
1602
+ open counted SEPARATELY; an absent ledger prints "no recorded history" and an unreadable one prints
1603
+ the store's own words while the advice still prints. Exit 0 for every legal step class; 2 on usage.
1565
1604
 
1566
1605
  register appends the wave's PRE-REGISTRATION record (immutable per wave: a second one refuses).
1567
1606
  observe appends ONE observation — provenance ${OBSERVATION_PROVENANCE.join(' or ')} only, since
@@ -1653,6 +1692,24 @@ degrade appends the recorded no-fold closure, threaded (with --nonce) or PRE-DIS
1653
1692
  pre-dispatch form opens no nonce thread, so aggregate REFUSES the whole wave by name once one is
1654
1693
  recorded — stated here because it is a live cost of writing that record.
1655
1694
 
1695
+ handoff-return is the worktree-stream return rung: run FROM MAIN after land --prepare, it locates
1696
+ the satellite through the handoff identity, DELIVERS every user-owned fragment of the handoff byte
1697
+ verbatim (with its boundaries and byte lengths, naming the MAIN-owned destinations), requires BOTH
1698
+ prepared-tree and prepared-head from the record and re-attests them against MAIN's staged write-tree
1699
+ and HEAD (a record with no prepared-head was written by an earlier kit — re-run land --prepare), and
1700
+ appends ONE self-reported worktree-stream observation (numerator: the prepared change set's blob
1701
+ bytes, read from the ATTESTED tree itself via git cat-file — never from disk, which an unstaged
1702
+ edit after the prepare moves silently; denominator: the handoff byte count) ONLY when the prepared
1703
+ change set lies wholly inside the observation domain, re-checking the staged write-tree and HEAD
1704
+ once more immediately before EITHER answer (the pre-append idiom: it narrows the race window rather
1705
+ than closing it). A deletion, a rename's absent old side, a symlink, a submodule, a mode-only
1706
+ change, a path whose name is not valid UTF-8, and every other unrepresentable form end instead with
1707
+ "observation: NOT RECORDED — <form> at <path> is outside the observation domain" at exit 0,
1708
+ delivery and proof still printed — no partial scope is ever recorded. The handoff digest and the two OIDs are the rung's printed PROOF,
1709
+ not ledger fields (the closed observation key set carries no artifact digest — an accepted
1710
+ limitation). The fold stays orchestrator judgment, and a fold landed after the gates leaves them
1711
+ stale: the printed next-step order says so.
1712
+
1656
1713
  aggregate reports ONE wave: the registered thresholds, every observation (context, never acceptance),
1657
1714
  and per registered step class the delegated threads with the D7 inclusion table applied — a folded
1658
1715
  success with an eligible wrapper-git metric contributes its L; a folded success whose metric is
@@ -1687,7 +1744,9 @@ against the payload, not claimed away here. A receipt is forgeable exactly like
1687
1744
  and D10 stands as a BAR, not a mechanism — at most one in-tree exec dispatch at a time, and nothing
1688
1745
  refuses a second.
1689
1746
 
1690
- Never commits, never runs a subscription CLI, spawns nothing but git READS. Exit codes: 0 success;
1747
+ Never commits, never runs a subscription CLI, spawns nothing but git READS except handoff-return,
1748
+ which attests MAIN's index with git write-tree: that may write a tree OBJECT into the odb and moves
1749
+ no ref (the same probe land --prepare itself uses). Exit codes: 0 success;
1691
1750
  1 a refusal (store STOP verbatim, a form violation, an unreadable file, a supervision question); 2
1692
1751
  usage; ${AWAIT_UNANSWERED_STATUS} an await that ended with no terminal receipt (the absolute deadline or the --timeout
1693
1752
  bound) — its own status so a caller that BRANCHES on the code can tell it from a refusal; a caller
@@ -1696,6 +1755,10 @@ that discards failure wholesale discards this one too.`;
1696
1755
  export const main = (argv, ctx = {}) => {
1697
1756
  const env = ctx.env ?? process.env;
1698
1757
  const now = ctx.now ?? (() => new Date().toISOString());
1758
+ // The ADVISORY lane's repository-root resolver, injectable for exactly one reason: the "the probe
1759
+ // threw" branch cannot be reached by arranging a directory, and an unexercised branch in a lane
1760
+ // whose whole claim is "it never owns an exit code" is the branch worth exercising.
1761
+ const repoRoot = ctx.repoRoot ?? resolveRepoRoot;
1699
1762
  try {
1700
1763
  // Help is the FIRST argument or nothing: past the verb, `--help` is an ordinary operand or an
1701
1764
  // already-claimed flag value, so `check --help` reads a file by that name rather than turning a
@@ -1703,18 +1766,20 @@ export const main = (argv, ctx = {}) => {
1703
1766
  if (HELP_FLAGS.has(argv[0])) return { code: 0, stdout: HELP, stderr: '' };
1704
1767
  const [verb, ...rest] = argv;
1705
1768
  const baseCwd = ctx.cwd ?? process.cwd();
1706
- if (verb === 'check') return runCheck({ argv: rest, baseCwd });
1769
+ if (verb === 'check') return runCheck({ argv: rest, baseCwd, env, repoRoot });
1770
+ if (verb === 'advise') return runAdvise({ argv: rest, baseCwd, env, repoRoot });
1707
1771
  if (verb === 'register') return runRegister({ baseCwd, env, argv: rest, now });
1708
1772
  if (verb === 'observe') return runObserve({ baseCwd, env, argv: rest, now });
1709
1773
  if (verb === 'open') return runOpen({ baseCwd, env, argv: rest, now });
1710
1774
  if (verb === 'return') return runReturn({ baseCwd, env, argv: rest, now });
1711
1775
  if (verb === 'fold') return runFold({ baseCwd, env, argv: rest, now });
1712
1776
  if (verb === 'degrade') return runDegrade({ baseCwd, env, argv: rest, now });
1777
+ if (verb === 'handoff-return') return runHandoffReturn({ baseCwd, env, argv: rest, now });
1713
1778
  if (verb === 'aggregate') return runAggregate({ baseCwd, env, argv: rest });
1714
1779
  if (verb === 'await') {
1715
1780
  throw usageFail('await is the one verb that WAITS, so it answers through mainAwait (the CLI routes it there) — main() returns the answer a verb has already computed, and a promise handed back here would read as a result object with no code at all');
1716
1781
  }
1717
- throw usageFail(`unknown verb: ${verb ?? '(none)'} — expected check | register | observe | open | await | return | fold | degrade | aggregate (see --help)`);
1782
+ throw usageFail(`unknown verb: ${verb ?? '(none)'} — expected check | advise | register | observe | open | await | return | fold | degrade | handoff-return | aggregate (see --help)`);
1718
1783
  } catch (err) {
1719
1784
  return { code: err.exitCode ?? 1, stdout: '', stderr: `dispatch: ${err.message}` };
1720
1785
  }
@@ -19,9 +19,7 @@
19
19
  // Read-only: never writes, never commits, never runs a subscription CLI, spawns nothing. Dependency-
20
20
  // free, Node >= 22. No side effects on import (the isDirectRun idiom).
21
21
 
22
- import { readFileSync } from 'node:fs';
23
- import { dirname, resolve } from 'node:path';
24
- import { fileURLToPath, pathToFileURL } from 'node:url';
22
+ import { pathToFileURL } from 'node:url';
25
23
  import { EXIT as DOCTOR_EXIT, STATUS as DOCTOR_STATUS, TRUSTED_DIRS as DOCTOR_TRUSTED_DIRS } from './autonomy-doctor.mjs';
26
24
  import {
27
25
  RECOMMENDATIONS_SECTION_HEADER,
@@ -39,6 +37,12 @@ import { PARITY } from './refresh-parity.mjs';
39
37
  import { HOST_HONORS_QUALIFIER } from './velocity-profile.mjs';
40
38
  import { LATENT_ARM_NOTICE } from './review-state.mjs';
41
39
  import { QUEUE_SHARED_RULE, LANDING_FROM_MAIN, NO_DEPENDENCIES_POSTURE, CLEANUP_OWNERSHIP_RULE, INCLUDE_IDENTITY_RULE, RESUME_VERIFY_RULE } from './worktrees.mjs';
40
+ // The one-writer BAR the satellite cold-start prompt states at both of its print sites — read from
41
+ // the composer that emits it, not from the worktrees tool, so the lint keeps the leaf's own words.
42
+ import { ONE_WRITER_BAR } from './worktree-prompt.mjs';
43
+ // The after-the-fold order the handoff-return rung prints (D9) — bound from the rung that owns it,
44
+ // so the Landing-flow doc can never drift from what the tool actually tells an operator to do.
45
+ import { AFTER_FOLD_ORDER } from './worktree-handoff-return.mjs';
42
46
  // The flow contract constants: the accepted schema version + the honest lagging-kit sentence
43
47
  // (owned by the config validator), and the set-flow bookkeeping-floor residual (owned by the
44
48
  // arming writer) — each pinned byte-exact into its mode doc(s).
@@ -47,6 +51,14 @@ import { FLOW_BOOKKEEPING_FLOOR_RESIDUAL } from './set-flow.mjs';
47
51
  import { FLOW_ARMED_HALVES_HEADER } from './procedures.mjs';
48
52
  import { RECEIPT_DEADLINE_CONTRACT } from './receipt-deadline.mjs';
49
53
  import { DISPATCH_CONTRACT } from './dispatch.mjs';
54
+ // The routing advisor's two bound sentences, plus the matrix STRUCTURE check that holds the mode
55
+ // doc's routing table to the advisor's frozen registry cell for cell — a correspondence no per-row
56
+ // token check could establish, since every token survives a reorder.
57
+ import { ADVISOR_NO_GATE, HARNESS_SUBAGENT_LANE, ADVISOR_PROBE_POSTURE } from './dispatch-advisor.mjs';
58
+ import {
59
+ ADVISOR_MATRIX_DOC, ADVISOR_MATRIX_BEGIN, ADVISOR_MATRIX_END,
60
+ parseAdvisorMatrix, checkMatrixStructure, readKitDoc,
61
+ } from './advisor-matrix.mjs';
50
62
  // The coverage vocabulary leaf: a CLOSED value set the gates contract doc must enumerate.
51
63
  import { COVERAGE } from './coverage-state.mjs';
52
64
  // The canonical producer body: gates.md prints the whole command byte for byte, so the doc is a
@@ -58,8 +70,6 @@ import { COVERAGE_PRODUCER_BODY } from './coverage-producer.mjs';
58
70
  // implementation — and through it the orchestration writer — into its import graph.
59
71
  import { RELAYED_ENSURE_TOKENS, RELAYED_FAILURE_CAUSES } from './ensure-vocabulary.mjs';
60
72
 
61
- const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
62
-
63
73
  const AUTONOMY_DOCTOR_DOC = 'references/modes/autonomy-doctor.md';
64
74
  const RECOMMENDATIONS_DOC = 'references/modes/recommendations.md';
65
75
  const UPGRADE_DOC = 'references/modes/upgrade.md';
@@ -71,7 +81,9 @@ const PROCEDURES_DOC = 'references/modes/procedures.md';
71
81
  const SET_FLOW_DOC = 'references/modes/set-flow.md';
72
82
  const RECEIPT_DEADLINE_DOC = 'references/modes/receipt-deadline.md';
73
83
  const GATES_DOC = 'references/modes/gates.md';
74
- const DISPATCH_DOC = 'references/modes/dispatch.md';
84
+ // One literal for the dispatch mode doc: the structure leaf already names it as the file it anchors
85
+ // its table in, and a second copy here is exactly the drift this lint exists to catch.
86
+ const DISPATCH_DOC = ADVISOR_MATRIX_DOC;
75
87
 
76
88
  // A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
77
89
  const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
@@ -151,6 +163,11 @@ export const BINDINGS = Object.freeze([
151
163
  // prose-only bars a doc could silently drop, so all are pinned to the live strings the record
152
164
  // actually carries.
153
165
  valueBinding('queue-shared-rule', QUEUE_SHARED_RULE, QUEUE_SHARED_RULE, [WORKTREES_DOC]),
166
+ // D7, the same class: one writer per worktree is a BAR and nothing refuses a second writer. The
167
+ // prompt states it at every print site, so the mode doc must carry the exact words the satellite
168
+ // reads — a doc that softened it would promise a guard the tool does not have.
169
+ valueBinding('one-writer-bar', ONE_WRITER_BAR, ONE_WRITER_BAR, [WORKTREES_DOC]),
170
+ valueBinding('after-fold-order', AFTER_FOLD_ORDER, AFTER_FOLD_ORDER, [WORKTREES_DOC]),
154
171
  valueBinding('landing-from-main', LANDING_FROM_MAIN, LANDING_FROM_MAIN, [WORKTREES_DOC]),
155
172
  valueBinding('no-dependencies-posture', NO_DEPENDENCIES_POSTURE, NO_DEPENDENCIES_POSTURE, [WORKTREES_DOC]),
156
173
  // The cleanup-ownership contract (AD-069): the exact live sentence every ownership STOP emits —
@@ -185,6 +202,15 @@ export const BINDINGS = Object.freeze([
185
202
  // doc — a doc that softened either would promise a judgment the checker never makes, or a number
186
203
  // the aggregator refuses to compute.
187
204
  valueBinding('dispatch-contract', DISPATCH_CONTRACT, DISPATCH_CONTRACT, [DISPATCH_DOC]),
205
+ // The routing advisor's honesty pair (delegation Plan 3): what the advice is NOT (it never gates),
206
+ // and the one lane that carries no availability verdict at all. Both were prose bars a doc edit
207
+ // could soften into a promise the module does not make.
208
+ valueBinding('advisor-no-gate', ADVISOR_NO_GATE, ADVISOR_NO_GATE, [DISPATCH_DOC]),
209
+ valueBinding('harness-subagent-lane', HARNESS_SUBAGENT_LANE, HARNESS_SUBAGENT_LANE, [DISPATCH_DOC]),
210
+ // And the verb's honest PROBE posture: "spawns nothing" was true of the module and false of the
211
+ // verb, which resolves the store path and the vehicle anchor through git. Stated once, live, so
212
+ // the mode doc, the HELP and the module header can never drift into three different claims.
213
+ valueBinding('advisor-probe-posture', ADVISOR_PROBE_POSTURE, ADVISOR_PROBE_POSTURE, [DISPATCH_DOC]),
188
214
  // The runner's `coverage=` summary vocabulary (Decision 8): the gates contract doc enumerates the
189
215
  // CLOSED value set, so a renamed or added value fails here instead of leaving the doc describing
190
216
  // a vocabulary the runner no longer speaks. One binding per value — the set is small and closed.
@@ -215,22 +241,35 @@ export const checkBinding = (binding, readText) => {
215
241
  return { constant: binding.constant, token: binding.token, files, ok: files.every((f) => f.ok) };
216
242
  };
217
243
 
218
- const defaultReadText = (rel) => readFileSync(resolve(KIT_ROOT, rel), 'utf8');
244
+ const defaultReadText = readKitDoc;
245
+
246
+ // The matrix STRUCTURE check rides beside the bindings, from its own leaf: a binding proves a token
247
+ // is somewhere in a file, while correspondence — one row per registry row, in order, every CELL
248
+ // equal — is a different claim with its own parser and its own refusal vocabulary. Re-exported here
249
+ // so the lint stays the ONE surface a caller reaches either check through.
250
+ export { ADVISOR_MATRIX_DOC, ADVISOR_MATRIX_BEGIN, ADVISOR_MATRIX_END, parseAdvisorMatrix, checkMatrixStructure, readKitDoc };
219
251
 
220
252
  // checkParity(bindings, readText) → [ per-binding result ]. Default reads the real modes/*.md files
221
253
  // relative to the kit root.
222
254
  export const checkParity = (bindings = BINDINGS, readText = defaultReadText) => bindings.map((b) => checkBinding(b, readText));
223
255
 
224
256
  // ── rendering ───────────────────────────────────────────────────────────────────────
225
- const formatHuman = (results) => {
257
+ const structureLine = (structure) =>
258
+ ` ${structure.ok ? '✓' : '✗'} ${structure.constant} → ${structure.files[0].rel}${structure.ok ? '' : ` — ${structure.files[0].reason}`}`;
259
+
260
+ const formatHuman = (results, structure) => {
226
261
  const lines = ['doc-parity — code constants ⟷ references/modes/*.md contract (read-only, BUGFREE-3)'];
227
262
  for (const r of results) {
228
263
  for (const f of r.files) {
229
264
  lines.push(` ${f.ok ? '✓' : '✗'} ${r.constant} → ${f.rel}${f.ok ? '' : ` — ${f.reason}`}`);
230
265
  }
231
266
  }
267
+ lines.push(structureLine(structure));
232
268
  const failed = results.flatMap((r) => r.files.filter((f) => !f.ok).map((f) => `${r.constant} @ ${f.rel}`));
233
- lines.push(` check: ${failed.length === 0 ? 'PASS' : 'FAIL'} — ${failed.length === 0 ? `${results.length} binding(s) consistent` : `${failed.length} drifted binding(s): ${failed.join('; ')}`}`);
269
+ // The summary verdict derives from BOTH halves. A PASS token computed from the bindings alone read
270
+ // as a green report while the structure check below it said FAIL — the one surface that lied.
271
+ const green = failed.length === 0 && structure.ok;
272
+ lines.push(` check: ${green ? 'PASS' : 'FAIL'} — ${failed.length === 0 ? `${results.length} binding(s) consistent` : `${failed.length} drifted binding(s): ${failed.join('; ')}`}, structure ${structure.ok ? 'PASS' : 'FAIL'}`);
234
273
  return lines.join('\n');
235
274
  };
236
275
 
@@ -248,13 +287,25 @@ orientation contract (shared-queue rule, landing-from-main, no-dependencies inst
248
287
  worktrees cleanup-ownership rule, the worktrees include-identity rule, the worktrees
249
288
  resume-verify rule, the flow tolerate contract (the accepted flow schema version + the
250
289
  lagging-kit sentence, procedures.md), the receipt-deadline arrival contract, the dispatch engine's
251
- FORM-only + aggregate-refusal contract (dispatch.md), the runner's closed coverage= summary
290
+ FORM-only + aggregate-refusal contract and the routing advisor's two honesty sentences — the
291
+ advice never gates, and the harness-subagent lane carries no availability verdict (dispatch.md), the
292
+ runner's closed coverage= summary
252
293
  vocabulary (gates.md), and the canonical coverage-producer-body command the same doc prints in full
253
294
  (gates.md) — to
254
295
  the exact token its references/modes/*.md contract must carry, and
255
296
  asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
256
297
  or an absent token FAILS CLOSED.
257
298
 
299
+ Beside the bindings runs ONE structure check: the dispatch mode doc's routing matrix must CORRESPOND
300
+ to the frozen advisor registry — one row per step class, in registry order, no duplicates, and every
301
+ CELL of every row equal to the registry's (vehicle, availability and returns alike), with the column
302
+ arity pinned so a fifth cell cannot sit there unread. A reorder, a duplicate, a dropped row, a
303
+ mis-bound vehicle and a drifted availability or returns cell each leave every token present, so a
304
+ token check would pass every one of them; this one names the first CELL that disagrees. The table is
305
+ read only from the surface anchored between the doc's advisor-matrix begin/end markers, so a copy
306
+ elsewhere in the doc can neither stand in for it nor mask its drift, and a drifted header inside the
307
+ anchor fails closed rather than falling through to another table.
308
+
258
309
  --check exits 0/1 as a gate (declare it in docs/ai/gates.json by hand). --json prints the structured
259
310
  result. Default prints the per-binding report.
260
311
 
@@ -270,15 +321,18 @@ export const main = (argv, ctx = {}) => {
270
321
  const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
271
322
  if (unknown !== undefined) throw usageFail(`unknown argument: ${unknown}`);
272
323
  const results = checkParity(BINDINGS, readText);
324
+ const structure = checkMatrixStructure(readText);
273
325
  const failed = results.filter((r) => !r.ok);
326
+ const green = failed.length === 0 && structure.ok;
274
327
  if (argv.includes('--json')) {
275
- return { code: argv.includes('--check') && failed.length > 0 ? 1 : 0, stdout: JSON.stringify({ results, ok: failed.length === 0 }, null, 2), stderr: '' };
328
+ return { code: argv.includes('--check') && !green ? 1 : 0, stdout: JSON.stringify({ results, structure, ok: green }, null, 2), stderr: '' };
276
329
  }
277
330
  if (argv.includes('--check')) {
278
- const reason = failed.length === 0 ? `${results.length} binding(s) consistent` : `${failed.length} drifted binding(s): ${failed.map((r) => r.constant).join(', ')} — update the contract doc(s) in the SAME edit as the code`;
279
- return { code: failed.length === 0 ? 0 : 1, stdout: `doc-parity check: ${failed.length === 0 ? 'PASS' : 'FAIL'} — ${reason}`, stderr: '' };
331
+ const bindingReason = failed.length === 0 ? `${results.length} binding(s) consistent` : `${failed.length} drifted binding(s): ${failed.map((r) => r.constant).join(', ')} — update the contract doc(s) in the SAME edit as the code`;
332
+ const structureReason = structure.ok ? 'the advisor matrix structure corresponds' : `the advisor matrix structure DRIFTED — ${structure.files[0].reason}`;
333
+ return { code: green ? 0 : 1, stdout: `doc-parity check: ${green ? 'PASS' : 'FAIL'} — ${bindingReason}; ${structureReason}`, stderr: '' };
280
334
  }
281
- return { code: 0, stdout: formatHuman(results), stderr: '' };
335
+ return { code: 0, stdout: formatHuman(results, structure), stderr: '' };
282
336
  } catch (err) {
283
337
  return { code: err.exitCode ?? 1, stdout: '', stderr: `doc-parity: ${err.message}` };
284
338
  }
@@ -8,8 +8,8 @@
8
8
  // index docs/ai/index.md regenerate-if-missing-or-stale (a GENERATED artifact)
9
9
  //
10
10
  // Each was prose in references/modes/upgrade.md that an agent performed by hand. One command instead
11
- // of four is deliberate: four independent runs would be four chances to skip one, and the mode doc now
12
- // has a single invocation point whose four outcome lines it relays.
11
+ // of five is deliberate: five independent runs would be five chances to skip one, and the mode doc now
12
+ // has a single invocation point whose five outcome lines it relays.
13
13
  //
14
14
  // The contract (pinned by this module's tests):
15
15
  // • --reconcile is REQUIRED. A bare run is a usage error, so nothing writes by accident.
@@ -17,7 +17,7 @@
17
17
  // • The ops run in a FIXED order and one op's failure NEVER skips the rest: every op reports its own
18
18
  // token, and the exit is non-zero when any of them failed.
19
19
  // • The deployment gate runs ONCE, before any op: an absent/symlinked docs/ai stops the whole run
20
- // with the gate's own message rather than four copies of it.
20
+ // with the gate's own message rather than five copies of it.
21
21
  //
22
22
  // Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when narrating.
23
23
  // Exit codes: 0 every op fine · 1 an op failed, or the deployment gate stopped the run · 2 usage.
@@ -138,7 +138,7 @@ export const main = (argv = [], ctx = {}) => {
138
138
  const cwd = resolve(args.cwd ?? ctx.cwd ?? process.cwd());
139
139
  const deps = ctx.deps ?? {};
140
140
  // ONE deployment gate for the whole run (see the header): with no docs/ai there is nothing to
141
- // reconcile, and four identical STOPs would read as four separate problems.
141
+ // reconcile, and five identical STOPs would read as five separate problems.
142
142
  assertDocsAiDeployment(cwd, deps, { noun: 'the project configuration', rel: 'under docs/ai' });
143
143
  const outcomes = runEnsures({ cwd, kitRoot: ctx.kitRoot ?? KIT_ROOT, dryRun: args.dryRun, deps, only: args.only });
144
144
  return {