@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
@@ -11,7 +11,15 @@
11
11
  // up to INDEX_LAG_PATH_CAP with the remainder stated. A dirty tracked SUBMODULE is named
12
12
  // separately with its own recovery. Fail-closed on an undecidable probe. This BLOCKS the
13
13
  // deliberate partial commit by design — `--no-verify` is the stated residual, not a flag;
14
- // 1. recomputes the CURRENT tree fingerprint (the review-state export — read-only git plumbing);
14
+ // 1. recomputes the CURRENT tree fingerprint (the review-state export — read-only git plumbing),
15
+ // and decides the two CONTENT-FREE lanes here, because no store read can answer them: a
16
+ // payload with no bytes yields the ONE fingerprint every clean moment of every repository
17
+ // shares, so any receipt at it was minted elsewhere and may attest nothing. With a DIRTY
18
+ // index that means staged content the payload cannot see (a gitlink hidden by
19
+ // `submodule.<name>.ignore` / `diff.ignoreSubmodules`) and the guard REFUSES, naming the
20
+ // configuration rather than re-staging; with a clean index the commit introduces no bytes
21
+ // (`--allow-empty`, a message-only `--amend`, an empty merge) and the guard PASSES while
22
+ // stating that it attests NOTHING — the receipt arms are skipped, never satisfied;
15
23
  // 2. reads the LATEST completed final-run record from the core-evidence store (only the latest
16
24
  // attempt at a fingerprint is authoritative — a green receipt is DEAD once a later attempt at
17
25
  // the same fingerprint went red) and refuses on: no record for the current fingerprint · a
@@ -38,7 +46,10 @@ import { pathToFileURL, fileURLToPath } from 'node:url';
38
46
  import { spawnSync } from 'node:child_process';
39
47
  import { createHash } from 'node:crypto';
40
48
  import { computeTreeFingerprint, buildState, decideCheck, quoteReportName, shellQuoteArg } from './review-state.mjs';
41
- import { resolveEvidencePath, readEvidence, authoritativeOfKind, canonicalKindSerialization, computeWorkingState } from './core-evidence.mjs';
49
+ import {
50
+ resolveEvidencePath, readEvidence, authoritativeOfKind, canonicalKindSerialization,
51
+ computeWorkingState, CONTENT_FREE_FINGERPRINT,
52
+ } from './core-evidence.mjs';
42
53
  import { resolveLcovPath } from './coverage-check.mjs';
43
54
  import { GATES_REL, loadDeclaration } from './run-gates.mjs';
44
55
  import { computeFlowDecision } from './flow-check.mjs';
@@ -177,15 +188,42 @@ export const decideIndexLag = (state) => {
177
188
  };
178
189
 
179
190
  // runGuard({ cwd, env }) → { code, lines }. Every refusal names its recovery.
191
+ // The flow decision's two renders, shared by every lane that consults it — the empty-commit lane
192
+ // reaches the same store through the same consumer mode, so its wording can never drift from the
193
+ // byte-carrying one.
194
+ const flowRefusalLines = (flow) => [
195
+ `commit-guard: REFUSED — the flow store refuses this commit: ${flow.refusals[0]}`,
196
+ ...flow.refusals.slice(1).map((r) => `commit-guard: flow refusal — ${r}`),
197
+ ];
198
+ const flowAdvisoryLines = (flow) => (flow.present && flow.armed
199
+ ? flow.advisories.map((a) => `commit-guard: flow advisory — ${a}`)
200
+ : []);
201
+
180
202
  export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
181
203
  const rootTop = gitLine(['rev-parse', '--show-toplevel'], cwd);
182
204
  if (rootTop == null) return { code: 1, lines: ['commit-guard: not a git work tree — nothing to guard'] };
183
205
  // FIRST: a pure tree property needing no store read. Its recovery re-stages the tree and re-mints
184
206
  // the receipt, so every arm below is re-decided anyway — naming a stale fingerprint ahead of it
185
207
  // would send the operator down a recovery they must redo.
186
- const indexLag = decideIndexLag(computeWorkingState(cwd));
208
+ const working = computeWorkingState(cwd);
209
+ const indexLag = decideIndexLag(working);
187
210
  if (indexLag !== null) return indexLag;
188
211
  const fingerprint = computeTreeFingerprint(cwd);
212
+ // The CONTENT-FREE lanes — the second pure tree property, decided here for the same reason the
213
+ // index lag is: no store read can answer it. A payload with no bytes states nothing about what
214
+ // this commit will carry, and its fingerprint is the ONE value every clean moment of every
215
+ // repository shares, so a receipt found at it was minted by some other moment, possibly at
216
+ // another base. Such evidence must therefore decide NOTHING here — neither refuse nor attest
217
+ // (the same fact flow-check-rungs.mjs applies to a red final). The index tells the two lanes
218
+ // apart, and `computeWorkingState` probes it with --ignore-submodules=none precisely so a
219
+ // config-hidden gitlink cannot pass for a clean one.
220
+ const contentFree = fingerprint === CONTENT_FREE_FINGERPRINT;
221
+ if (contentFree && working.stagedDirty) {
222
+ return {
223
+ code: 1,
224
+ lines: [`commit-guard: REFUSED — the index carries staged content the fingerprint domain cannot see (a submodule gitlink hidden from \`git diff\` by \`submodule.<name>.ignore\` or \`diff.ignoreSubmodules\`), so no final receipt can describe what this commit will carry. Recovery: clear that ignore setting (or set it to \`none\`) until \`git diff --cached --no-ext-diff\` shows the change, then re-run node ${FINAL_RUN_TOOL} --final`],
225
+ };
226
+ }
189
227
  // The guard's OWN reads resolve FIXED git-dir paths — a stray AW_CORE_EVIDENCE / AW_LCOV_FILE
190
228
  // in the committing shell must never redirect the LAST line of defense to a forged artifact
191
229
  // (the env stays a test seam for the producers, never for this consumer).
@@ -194,6 +232,27 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
194
232
  if ((read.malformed ?? 0) > 0 || read.readError) {
195
233
  return { code: 1, lines: [`commit-guard: REFUSED — evidence store unavailable (${read.malformed} malformed line(s)${read.readError ? `, read error: ${read.readError}` : ''}); inspect ${storePath}`] };
196
234
  }
235
+ // The empty-commit lane: the index equals HEAD, so this commit introduces no bytes at all
236
+ // (`git commit --allow-empty`, a message- or signature-only `--amend`, an empty merge). The
237
+ // guard's whole claim is about bytes, so here it has none to make and says so. The receipt arms
238
+ // are SKIPPED rather than satisfied — consulting a content-free receipt would make the outcome
239
+ // depend on which stray clean moment happened to be recorded last. The flow arm still runs: an
240
+ // empty commit still moves HEAD, and the chain bookkeeping is about that, not about bytes; its
241
+ // own fingerprint-keyed correlations (the D10 flow→final binding, receipt and degrade coverage)
242
+ // drop out inside flow-check on the same fact, so no stray content-free record decides here
243
+ // either. Store HEALTH is deliberately NOT waived above: an unreadable store is not a
244
+ // correlation, and a store that cannot be read cannot answer the chain questions either.
245
+ if (contentFree) {
246
+ const emptyFlow = computeFlowDecision({ cwd, consumer: 'commit-guard', treeCarriesBytes: false });
247
+ if (emptyFlow.refusals.length > 0) return { code: 1, lines: flowRefusalLines(emptyFlow) };
248
+ return {
249
+ code: 0,
250
+ lines: [
251
+ 'commit-guard: PASS — this commit changes no tree content (the index contributes no tree-content delta and the work tree adds nothing), so the guard attests NOTHING about it: a receipt found at the shared content-free fingerprint cannot be correlated to THIS moment or base',
252
+ ...flowAdvisoryLines(emptyFlow),
253
+ ],
254
+ };
255
+ }
197
256
  const finals = authoritativeOfKind(read.records, 'final');
198
257
  const current = finals.find((r) => r.fingerprintBefore === fingerprint) ?? null;
199
258
  if (!current) {
@@ -255,15 +314,7 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
255
314
  // evidenceHashes.flow and the store has since VANISHED (present=false) — a deletion must
256
315
  // never un-arm the binding. A no-store repo with no flow-bearing receipt still yields zero
257
316
  // refusals (byte-exact pre-flow behavior).
258
- if (flow.refusals.length > 0) {
259
- return {
260
- code: 1,
261
- lines: [
262
- `commit-guard: REFUSED — the flow store refuses this commit: ${flow.refusals[0]}`,
263
- ...flow.refusals.slice(1).map((r) => `commit-guard: flow refusal — ${r}`),
264
- ],
265
- };
266
- }
317
+ if (flow.refusals.length > 0) return { code: 1, lines: flowRefusalLines(flow) };
267
318
  // The ship-receipt arm: the SAME normative decision review-state --check computes, over a
268
319
  // SANITIZED env — the receipts/evidence/flow-store overrides are producer test seams, and
269
320
  // honoring them HERE would let a forged store bypass the fixed-path reads above.
@@ -278,10 +329,7 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
278
329
  const flowSuffix = flow.present && flow.armed
279
330
  ? ` — flow: armed${review.flowLabels?.length ? ` (${review.flowLabels.join('; ')})` : ''}`
280
331
  : '';
281
- const flowAdvisoryLines = flow.present && flow.armed
282
- ? flow.advisories.map((a) => `commit-guard: flow advisory — ${a}`)
283
- : [];
284
- return { code: 0, lines: [`commit-guard: PASS — a green final receipt binds this exact tree (${fingerprint.slice(0, 12)}…), the declaration and evidence hashes match, and the review obligations are satisfied${flowSuffix}`, ...flowAdvisoryLines] };
332
+ return { code: 0, lines: [`commit-guard: PASS — a green final receipt binds this exact tree (${fingerprint.slice(0, 12)}…), the declaration and evidence hashes match, and the review obligations are satisfied${flowSuffix}`, ...flowAdvisoryLines(flow)] };
285
333
  };
286
334
 
287
335
  const HELP = `commit-guard — the read-only pre-commit guard (agent-workflow family, D10).
@@ -291,7 +339,16 @@ Usage:
291
339
 
292
340
  Re-runs NOTHING: refuses an INDEX that lags the verified working tree (FIRST — unstaged tracked
293
341
  paths, reviewable untracked paths, or a dirty tracked submodule, each named with its recovery;
294
- this deliberately blocks a partial commit), then recomputes the current tree fingerprint and binds
342
+ this deliberately blocks a partial commit), then recomputes the current tree fingerprint.
343
+
344
+ A CONTENT-FREE fingerprint (a payload with no bytes — the value every clean work tree shares)
345
+ decides WITHOUT a receipt, because one found there was minted by another clean moment: with a dirty
346
+ index it REFUSES (staged content the payload cannot see — a gitlink hidden by
347
+ \`submodule.<name>.ignore\` / \`diff.ignoreSubmodules\`; the recovery is that configuration, not
348
+ \`git add\`), and with a clean index it PASSES stating it attests NOTHING (the commit carries no
349
+ bytes: \`--allow-empty\`, a message-only \`--amend\`, an empty merge).
350
+
351
+ Otherwise it binds
295
352
  the LATEST completed run-gates --final receipt — refusing on { no receipt for this tree · a red
296
353
  latest attempt · before≠after · declaration content drift · evidence-hash drift · lcov drift ·
297
354
  a flow-store refusal (a PRESENT store's open own chain / base motion / coverage — verbatim; no
@@ -160,6 +160,16 @@ export const computeTreeFingerprint = (cwd, fsx) => {
160
160
  return payload == null ? null : createHash('sha256').update(payload).digest('hex');
161
161
  };
162
162
 
163
+ // The fingerprint of a CONTENT-FREE payload — a clean work tree emits no bytes at all, so this ONE
164
+ // value is shared by every clean moment of every repository. It therefore identifies no working
165
+ // state and correlates to no base: evidence found at it was minted by some other clean moment,
166
+ // possibly at another base, and can decide nothing in either direction. Two situations reach it,
167
+ // and only the INDEX tells them apart (never the payload): an empty commit, where the index equals
168
+ // HEAD and no byte enters the repository, and staged content the payload cannot see — a gitlink
169
+ // hidden from `git diff` by an ignore configuration. Read by the consumers that correlate a
170
+ // fingerprint to a base (flow-check-rungs.mjs #65) and by commit-guard's two content-free lanes.
171
+ export const CONTENT_FREE_FINGERPRINT = createHash('sha256').update(Buffer.alloc(0)).digest('hex');
172
+
163
173
  // The index↔worktree split the fingerprint deliberately CANNOT see: the payload above concatenates
164
174
  // the staged and unstaged diffs, so against an otherwise-empty index a hunk moving into the index
165
175
  // leaves it byte-identical — while `git commit` builds the commit from the INDEX alone. This is the
@@ -141,6 +141,7 @@ const RAW_BACKENDS = [
141
141
  ],
142
142
  receipt: "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan/diff mode; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (code mode refuses pre-spend without one — no run, no receipt — unless --ungrounded/AGY_PROBE=1; in plan/diff an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); probe = whether the run relaxed the quality guards (AGY_PROBE=1), written on EVERY receipt so it self-declares — the kit's review-state gate rejects a probe-marked receipt (a probe review never attests) and equally rejects an unmarked one (silence is not a declaration); posture = the ACTUAL run posture {model} (agy has no tier), written on EVERY receipt (D5) — the gate rejects a receipt with an absent/invalid posture (a pre-D5 wrapper minted it; re-run the review), one stderr banner line states the same posture, an ATTESTING review with AGY_MODEL explicitly emptied refuses pre-spend, and a model string carrying control bytes refuses pre-spend in every mode; delivery = how the change set REACHED the model, currently emitted as 'inline' (the whole set rode one prompt — proven by construction) or 'fed' (a chunked feed whose per-part echo proof verified); REQUIRED on every agy code receipt and its ABSENCE is what stops a pre-fed-lane receipt attesting, while the gate accepts any well-formed declaration rather than a particular value; absent by construction on plan/diff/continuation receipts, which carry no change set; a run whose output carries NO recognized '### Verdict' section — empty output included — exits 4 with NO receipt (D4: a FAILED review to RE-RUN, never a fatal session error); when the dispatch nonce seam is supplied — the AW_REVIEW_NONCE environment value or its plain-argument equivalent --nonce <n> (one seam: the flag assigns the same value; supplying both with different values refuses pre-spend) — under the safe grammar [A-Za-z0-9._-]{1,64} (anything else refuses pre-spend), the wrapper first mints the finding MANIFEST {schema, backend, nonce, fingerprint, findings} beside the receipts file (agent-workflow-finding-manifest-<backend>-<nonce>.json; atomic, no-clobber — a byte-identical rewrite is an idempotent no-op, different bytes refuse loudly) ORDERED before the receipt append — a failed manifest write EXCLUDES the receipt append, so a nonce-supplied dispatch can never land a receipt without its readable manifest; a nonce-less invocation adds NO nonce field and mints NO finding manifest (the existing wrapperVersion field still changes with each bridge release); a write failure warns, never fails the review",
143
143
  notes: [
144
+ 'transport: every review dispatch drives the CLI in --output-format json (plus --disable-slash-commands) and the returned envelope is parsed in node (bin/agy-envelope.mjs) — the operator-facing invocations and flags above do NOT change, and on a ZERO exit the wrapper still PRINTS the review text, never JSON. A missing or unreadable envelope on a zero exit is a loud failure with NO receipt, never a downgraded verdict and never a fallback to raw-stdout parsing; a non-zero CLI exit keeps its own code and message, and publishes the captured stdout unchanged from the SINGLE dispatch or the FINAL fed turn (which may therefore be a JSON or partial payload — the envelope is parsed only on a zero exit); an INTERMEDIATE feed turn is the exception, its output stays private (Invariant E) and its failure prints only a named error. Enforced by a PRE-SPEND capability probe, not a version floor: agy --help must advertise --output-format and --disable-slash-commands, node must be >= 22, and bin/agy-envelope.mjs must be present — otherwise the review refuses before any run is spent and names the missing capability',
144
145
  'pre-dispatch host-diff: before the FIRST dispatch of this bridge, diff its declared networkHosts against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE dispatching, never fired into a known prompt',
145
146
  'the review posture banner appends a banner-only timeout=<duration> field — exactly the duration agy-run hands to timeout(1); the hard-timeout preflight fails CLOSED when no timeout/gtimeout binary exists (the wrapper refuses by name before any CLI run, so an uncapped review run can no longer happen), and the field never enters the receipt posture or the D5 banner↔receipt parity',
146
147
  'quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated posture; a prose re-type drifts',
@@ -0,0 +1,323 @@
1
+ // dispatch-advisor.mjs — the vehicle-routing ADVISOR (delegation Plan 3, Phase 1; D1-D4).
2
+ //
3
+ // One question, one answer: *which vehicle carries this step class on THIS host, and what has the
4
+ // ledger already recorded for it.* That answer lived only in prose canon and in a remembered rule,
5
+ // so a cold session routed a sub-task from memory or not at all. It is now a printed block at two
6
+ // points of use — `dispatch advise` and the footer of a form-valid `dispatch check`.
7
+ //
8
+ // What it deliberately is NOT:
9
+ // • it never gates. Nothing here refuses a dispatch, and the footer prints ONLY over a form-valid
10
+ // contract, so the checker's exit contract and FIRST line stay byte-identical whatever this
11
+ // module concludes. A choice that diverges from the advice is a NOTE, never a refusal.
12
+ // • THIS MODULE writes nothing and spawns nothing — but the VERB that prints it is not spawn-free,
13
+ // and the honest split is stated in ADVISOR_PROBE_POSTURE below rather than claimed away here.
14
+ // Host capability is filesystem-only: the execute backend through the detector (which spawns
15
+ // nothing by a source-level pin), the cheap vehicles through the PRESENCE of
16
+ // `.claude/agents/<name>.md` at the repository top-level, which the CALLER resolves and declares.
17
+ // • it opens no second ledger door. The recorded history arrives as the CALLER's read outcome —
18
+ // `{ok:true, records}` or `{ok:false, reason}` — and is counted through the store's OWN thread
19
+ // walk (`delegationThreadState`), never a second walker. An unreadable store degrades the
20
+ // history line and leaves the advice standing.
21
+ //
22
+ // Portability is stated rather than assumed: the four bundled vehicles and the kit's own worktrees
23
+ // mode are portable rows; `doc-research` is HOST-LOCAL — a per-host web grant the kit does not
24
+ // bundle — and the host's own subagent lane is ASSUMED/manual, carrying no availability verdict at
25
+ // all. Dependency-free, Node >= 22. No side effects on import; no CLI (the verb lives on dispatch).
26
+
27
+ import { lstatSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { STEP_CLASSES } from './dispatch-record.mjs';
30
+ import { delegationThreadState } from './dispatch-store.mjs';
31
+ import { AGENTS_DIR, FALLBACK_LENS_ADDITIONAL_ONLY } from './cheap-agents.mjs';
32
+ import { detectBackends, wrapperCmdFor, READY } from './detect-backends.mjs';
33
+
34
+ // The two sentences doc-parity binds into references/modes/dispatch.md. Stated ONCE, here, so the
35
+ // mode doc can never drift into promising a gate this module does not own.
36
+ export const ADVISOR_NO_GATE = 'the advice never gates a dispatch: which vehicle carries a sub-task stays orchestrator judgment, and a divergence from the advice is recorded as a note rather than refused';
37
+ export const HARNESS_SUBAGENT_LANE = "the harness's own subagent lane is ASSUMED/manual — it is not kit-detectable, so it carries no availability verdict here and no acceptance weight";
38
+
39
+ // The other lane, always printed: an unavailable vehicle is not a dead end, it is a degrade you record.
40
+ export const ADVISOR_FALLBACK = 'solo (this orchestrator) — recorded as a degrade, never a silent skip';
41
+
42
+ // The verb's honest posture, bound into the mode doc and interpolated into the tool's HELP so the
43
+ // three surfaces state ONE thing. "Spawns nothing" was false of the VERB from the first line of this
44
+ // lane — the store path is resolved through git unless AW_DELEGATION_STORE names it — so the claim
45
+ // is stated where it is true (the module) and qualified where it is not (the verb).
46
+ export const ADVISOR_PROBE_POSTURE = 'the advisor module itself writes nothing, spawns nothing and opens no second ledger door; the VERB may run read-only git probes — the delegation store path (unless AW_DELEGATION_STORE names it outright) and the repository top-level the cheap vehicles are anchored at — and it never runs a vehicle, a subscription CLI, or anything that writes';
47
+
48
+ // The table's shape, stated ONCE. The header line, the alignment rule, the rendered rows and the
49
+ // parser's arity + per-cell diagnosis all DERIVE from this: three separate statements of one column
50
+ // order is exactly how a reordered header keeps a green gate over rows that no longer mean what
51
+ // their columns say.
52
+ export const ADVISOR_MATRIX_COLUMNS = Object.freeze([
53
+ Object.freeze({ key: 'stepClass', label: 'step class' }),
54
+ Object.freeze({ key: 'vehicle', label: 'vehicle' }),
55
+ Object.freeze({ key: 'availabilityNote', label: 'availability' }),
56
+ Object.freeze({ key: 'returns', label: 'returns' }),
57
+ ]);
58
+
59
+ const matrixRow = (cells) => `| ${ADVISOR_MATRIX_COLUMNS.map(({ key }) => cells[key]).join(' | ')} |`;
60
+
61
+ export const ADVISOR_MATRIX_HEADER = matrixRow(Object.fromEntries(ADVISOR_MATRIX_COLUMNS.map(({ key, label }) => [key, label])));
62
+ export const ADVISOR_MATRIX_RULE = matrixRow(Object.fromEntries(ADVISOR_MATRIX_COLUMNS.map(({ key }) => [key, '---'])));
63
+
64
+ // The execute backend is named once; its wrapper cmd is READ from the detector's role registry
65
+ // rather than re-typed, so a renamed wrapper moves this row with it.
66
+ const EXECUTE_BACKEND = 'codex-cli-bridge';
67
+ const EXECUTE_ROLE = 'execute';
68
+
69
+ const BUNDLED = 'bundled vehicle — present once placed in .claude/agents/';
70
+ const HOST_LOCAL = 'HOST-LOCAL — a per-host web grant, never bundled with the kit';
71
+
72
+ // ── the frozen row set: exactly one row per D9 step class ─────────────────────────────────────────
73
+ // Totality is a TEST, not a comment: a class added to dispatch-record.mjs reddens the advisor suite
74
+ // rather than printing nothing at the point of use.
75
+
76
+ const row = (entry) => Object.freeze(entry);
77
+
78
+ export const ADVISOR_ROWS = Object.freeze([
79
+ row({
80
+ stepClass: 'code',
81
+ vehicle: wrapperCmdFor(EXECUTE_BACKEND, EXECUTE_ROLE),
82
+ kind: 'backend',
83
+ backend: EXECUTE_BACKEND,
84
+ portable: true,
85
+ availabilityNote: 'execute backend — readiness read from the bridge install, never from a spawn',
86
+ returns: "a diff plus the wrapper's exec receipt",
87
+ why: 'a bounded code sub-task returns a diff you review + gate',
88
+ }),
89
+ row({
90
+ stepClass: 'extraction',
91
+ vehicle: 'mechanical-sweep',
92
+ kind: 'agent',
93
+ portable: true,
94
+ availabilityNote: BUNDLED,
95
+ returns: 'an extraction report you verify',
96
+ why: 'a mechanical multi-file sweep returns facts, on a read-only vehicle that can never shell out',
97
+ }),
98
+ row({
99
+ stepClass: 'triage',
100
+ vehicle: 'gate-triage',
101
+ kind: 'agent',
102
+ portable: true,
103
+ availabilityNote: BUNDLED,
104
+ returns: 'a structured gate-failure classification',
105
+ why: "a failing gate's output returns classified, never fixed — the fix stays yours",
106
+ }),
107
+ row({
108
+ stepClass: 'draft',
109
+ vehicle: 'changelog-skeleton',
110
+ kind: 'agent',
111
+ portable: true,
112
+ availabilityNote: BUNDLED,
113
+ returns: 'a factual skeleton',
114
+ why: 'the factual bones come back cheap; the lead and the final text stay yours',
115
+ }),
116
+ row({
117
+ stepClass: 'research',
118
+ vehicle: 'doc-research',
119
+ kind: 'agent',
120
+ portable: false,
121
+ availabilityNote: HOST_LOCAL,
122
+ returns: 'cited findings',
123
+ why: 'an external documentation question returns cited findings, on the one vehicle granted web access',
124
+ }),
125
+ row({
126
+ stepClass: 'review-opinion',
127
+ vehicle: 'review-lens',
128
+ kind: 'agent',
129
+ portable: true,
130
+ availabilityNote: BUNDLED,
131
+ returns: 'one additional review opinion',
132
+ why: FALLBACK_LENS_ADDITIONAL_ONLY,
133
+ }),
134
+ row({
135
+ stepClass: 'worktree-stream',
136
+ vehicle: 'worktrees',
137
+ kind: 'kit',
138
+ portable: true,
139
+ availabilityNote: 'ships with the kit — available wherever the kit is deployed',
140
+ returns: 'a prepared satellite diff plus its handoff',
141
+ why: 'a parallel feature stream runs in its own worktree and returns a prepared diff plus its handoff',
142
+ }),
143
+ ]);
144
+
145
+ export const advisorRow = (stepClass) => ADVISOR_ROWS.find((r) => r.stepClass === stepClass);
146
+
147
+ // ── host capability, resolved by filesystem facts only (D2) ───────────────────────────────────────
148
+
149
+ // The agent lane is FOUR-valued, and the two unknowns are kept apart because they are two different
150
+ // ignorances. `.claude/agents/` is a repository-ROOT surface, so a probe run from anywhere else
151
+ // proves nothing — an absent file under an UNANCHORED cwd is not evidence the vehicle is unplaced,
152
+ // and a PRESENT one there is not evidence it is the repository's vehicle either, since a nested
153
+ // shadow copy reads identically. Separately, an anchored probe can simply fail to answer (any errno
154
+ // but ENOENT — EACCES is not absence), and saying "the repository root was not resolved" there would
155
+ // be a false statement about a root that WAS resolved. Both render as `unknown`; each names its own
156
+ // cause.
157
+ export const AGENT_PRESENT = 'present';
158
+ export const AGENT_MISSING = 'missing';
159
+ export const AGENT_UNANCHORED = 'unanchored';
160
+ export const AGENT_PROBE_ERROR = 'probe-error';
161
+
162
+ // The states that are NOT a verdict — exported so a consumer can ask the question without re-deriving
163
+ // which values happen to be ignorance today.
164
+ export const AGENT_UNKNOWN_STATES = Object.freeze([AGENT_UNANCHORED, AGENT_PROBE_ERROR]);
165
+
166
+ // No-follow by construction: the placement writer refuses to write through a symlink, so a symlinked
167
+ // entry is not a vehicle this kit placed and is not counted as one.
168
+ const probeAgentFile = (cwd, name) => {
169
+ try {
170
+ return lstatSync(join(cwd, AGENTS_DIR, `${name}.md`)).isFile() ? AGENT_PRESENT : AGENT_MISSING;
171
+ } catch (err) {
172
+ return err?.code === 'ENOENT' ? AGENT_MISSING : AGENT_PROBE_ERROR;
173
+ }
174
+ };
175
+
176
+ // advisorDeps({cwd, anchored, detect}) → the injected host half. `anchored` states whether `cwd` IS
177
+ // the repository top-level; when it is not, the agent lane answers `unanchored` rather than guessing.
178
+ // Both probes are pure reads; `detect` is the detector's own no-spawn pass, resolved ONCE per call so
179
+ // seven rows cost one detection.
180
+ export const advisorDeps = ({ cwd = process.cwd(), anchored = true, detect = detectBackends } = {}) => {
181
+ let detected = null;
182
+ return {
183
+ agentState: (name) => (anchored ? probeAgentFile(cwd, name) : AGENT_UNANCHORED),
184
+ backendReadiness: (backend) => {
185
+ detected ??= detect();
186
+ return detected.find((b) => b.name === backend)?.readiness ?? 'not-installed';
187
+ },
188
+ };
189
+ };
190
+
191
+ const AGENT_LABELS = Object.freeze({
192
+ portable: Object.freeze({
193
+ [AGENT_PRESENT]: 'ready',
194
+ [AGENT_MISSING]: 'unavailable — not placed; run /agent-workflow-kit agents',
195
+ [AGENT_UNANCHORED]: 'unknown — the repository root was not resolved, so .claude/agents/ was never located',
196
+ [AGENT_PROBE_ERROR]: 'unknown — the repository root resolved, but .claude/agents/ could not be probed there',
197
+ }),
198
+ 'host-local': Object.freeze({
199
+ [AGENT_PRESENT]: 'ready — host-local',
200
+ [AGENT_MISSING]: 'unavailable — host-local, not bundled',
201
+ [AGENT_UNANCHORED]: 'unknown — host-local, and the repository root was not resolved',
202
+ [AGENT_PROBE_ERROR]: 'unknown — host-local, and .claude/agents/ could not be probed',
203
+ }),
204
+ });
205
+
206
+ // The label for a state this module does not recognize. It claims NOTHING — not that the root
207
+ // resolved, not that it did not. Reusing the probe-error wording here would assert "the repository
208
+ // root resolved" about a value that establishes no such thing, which is the same class of false
209
+ // statement the two named unknowns exist to avoid, one level down.
210
+ export const AGENT_UNRECOGNIZED_LABEL = 'unknown — the availability probe returned an unrecognized state, so nothing about this vehicle is established';
211
+
212
+ export const availabilityOf = (entry, deps) => {
213
+ if (entry.kind === 'kit') return { ready: true, label: 'ready — ships with the kit' };
214
+ if (entry.kind === 'backend') {
215
+ const readiness = deps.backendReadiness(entry.backend);
216
+ return readiness === READY
217
+ ? { ready: true, label: 'ready' }
218
+ : { ready: false, label: `unavailable — ${readiness}` };
219
+ }
220
+ const state = deps.agentState(entry.vehicle);
221
+ const labels = AGENT_LABELS[entry.portable ? 'portable' : 'host-local'];
222
+ return { ready: state === AGENT_PRESENT, label: labels[state] ?? AGENT_UNRECOGNIZED_LABEL };
223
+ };
224
+
225
+ // ── the recorded history: the store's OWN thread walk, over a CLOSED state taxonomy (D4) ──────────
226
+
227
+ const THREAD_KINDS = ['dispatch', 'return', 'fold', 'degrade'];
228
+
229
+ // The four states are exactly what delegationThreadState distinguishes. `open` is printed SEPARATELY
230
+ // and is never among the closed threads: a live thread is not evidence about a finished one.
231
+ const threadStateName = (state) => {
232
+ if (state.closure?.kind === 'fold') return 'folded';
233
+ if (state.closure?.kind === 'degrade') return 'degrade-closed';
234
+ return state.terminal ? 'failure-terminal' : 'open';
235
+ };
236
+
237
+ export const countThreadStates = (records, stepClass) => {
238
+ const counts = { folded: 0, 'failure-terminal': 0, 'degrade-closed': 0, open: 0 };
239
+ const seen = new Set();
240
+ for (const record of records) {
241
+ if (!THREAD_KINDS.includes(record?.kind)) continue;
242
+ const { nonce } = record;
243
+ // A PRE-DISPATCH degrade carries nonce null and belongs to no thread at all.
244
+ if (typeof nonce !== 'string' || nonce === '' || seen.has(nonce)) continue;
245
+ seen.add(nonce);
246
+ const state = delegationThreadState(records, nonce);
247
+ if (state.dispatch === null || state.dispatch.stepClass !== stepClass) continue;
248
+ counts[threadStateName(state)] += 1;
249
+ }
250
+ return counts;
251
+ };
252
+
253
+ const CLOSED_ORDER = ['folded', 'failure-terminal', 'degrade-closed'];
254
+
255
+ export const historyLine = (stepClass, ledger) => {
256
+ if (ledger?.ok !== true) {
257
+ return `history: unavailable — ${ledger?.reason ?? 'the ledger read reported no outcome at all'}`;
258
+ }
259
+ const counts = countThreadStates(ledger.records ?? [], stepClass);
260
+ const closed = CLOSED_ORDER.reduce((sum, key) => sum + counts[key], 0);
261
+ if (closed === 0 && counts.open === 0) return 'history: no recorded history';
262
+ const parts = CLOSED_ORDER.filter((key) => counts[key] > 0).map((key) => `${counts[key]} ${key}`);
263
+ const closedClause = closed === 0
264
+ ? '0 closed threads'
265
+ : `${closed} closed thread${closed === 1 ? '' : 's'} — ${parts.join(', ')}`;
266
+ return `history: ${closedClause} · ${counts.open} open`;
267
+ };
268
+
269
+ // ── rendering ─────────────────────────────────────────────────────────────────────────────────────
270
+
271
+ // renderAdvisorBlock({stepClass, ledger, deps}) → the five-line block, or null for an unknown class.
272
+ // The refusal of an unknown class belongs to the CALLER (a usage exit naming the closed set), not
273
+ // here: this module answers a question and never decides an exit code.
274
+ export const renderAdvisorBlock = ({ stepClass, ledger, deps }) => {
275
+ const entry = advisorRow(stepClass);
276
+ if (entry === undefined) return null;
277
+ const availability = availabilityOf(entry, deps);
278
+ return [
279
+ `dispatch advisor — step class: ${stepClass}`,
280
+ ` advice: ${entry.vehicle} (${availability.label}) — ${entry.why}`,
281
+ ` fallback: ${ADVISOR_FALLBACK}`,
282
+ ` ${historyLine(stepClass, ledger)}`,
283
+ ` note: ${ADVISOR_NO_GATE}`,
284
+ ].join('\n');
285
+ };
286
+
287
+ // renderSelectionNote({stepClass, vehicle}) → the divergence NOTE, or null when the contract's
288
+ // SELECTED vehicle is the advised one. `vehicle.requested` is named only when it differs from
289
+ // `selected` — where the pair agrees there is nothing to distinguish, and printing it twice would
290
+ // read as a second fact.
291
+ export const renderSelectionNote = ({ stepClass, vehicle }) => {
292
+ const entry = advisorRow(stepClass);
293
+ if (entry === undefined || vehicle == null) return null;
294
+ const { requested, selected } = vehicle;
295
+ if (selected === entry.vehicle) return null;
296
+ const requestedClause = requested !== selected ? ` (requested "${requested}")` : '';
297
+ return ` divergence: the contract selected "${selected}"${requestedClause}; the advisor advises "${entry.vehicle}" — a NOTE, never a refusal`;
298
+ };
299
+
300
+ // The harness lane, as a row rather than as a rendered string: it joins the table through the same
301
+ // column order as every other row, and it is NOT a step class — no availability verdict, no
302
+ // acceptance weight, and it never enters the registry the advice is read from.
303
+ export const HARNESS_LANE_ROW = Object.freeze({
304
+ stepClass: 'harness subagent',
305
+ vehicle: "the host's own",
306
+ availabilityNote: HARNESS_SUBAGENT_LANE,
307
+ returns: 'not measured',
308
+ });
309
+
310
+ // renderAdvisorMatrix() → the vehicle-routing matrix the mode doc carries, WHOLE: header, alignment
311
+ // rule, one row per registry row in registry order, and the lane. The doc's copy is held to this
312
+ // exact block by doc-parity's structure check, so a deleted rule row, a rewritten lane and an extra
313
+ // row are all caught by the same comparison that catches a drifted cell.
314
+ export const renderAdvisorMatrix = () => [
315
+ ADVISOR_MATRIX_HEADER,
316
+ ADVISOR_MATRIX_RULE,
317
+ ...ADVISOR_ROWS.map((entry) => matrixRow({ ...entry, stepClass: `\`${entry.stepClass}\`` })),
318
+ matrixRow(HARNESS_LANE_ROW),
319
+ ].join('\n');
320
+
321
+ // The closed class set, rendered for the caller's usage refusal — one source with the record
322
+ // vocabulary, so a class added there is offered here without a second edit.
323
+ export const ADVISOR_STEP_CLASSES = STEP_CLASSES;