@sabaiway/agent-workflow-kit 1.28.0 → 1.30.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.
@@ -18,10 +18,15 @@
18
18
 
19
19
  import { readFileSync, lstatSync } from 'node:fs';
20
20
  import { homedir } from 'node:os';
21
- import { pathToFileURL } from 'node:url';
21
+ import { join, dirname } from 'node:path';
22
+ import { pathToFileURL, fileURLToPath } from 'node:url';
22
23
  import { detectBackends, wrapperCmdFor, wrapperContractFor } from './detect-backends.mjs';
23
24
  import { ACTIVITIES, resolveActivityRecipe, planRecipe } from './recipes.mjs';
24
25
  import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
26
+ // The plan-in-flight detector (AD-038) — imported from the READ-ONLY checker (review-state.mjs
27
+ // performs no fs writes, so the "procedures never reaches a writer" import-split invariant holds;
28
+ // the WRITER-capable grounding.mjs is only NAMED in rendered text, never imported).
29
+ import { plansInFlight, PLANS_REL } from './review-state.mjs';
25
30
  // The config schema/read core lives in orchestration-config.mjs (the single config contract). procedures
26
31
  // is READ-ONLY: it imports the reader + the SHARED slot/recipe validity, never the fs-writer
27
32
  // (orchestration-write.mjs) — so "the read-only advisor can never reach a writer" is STRUCTURALLY true
@@ -188,6 +193,41 @@ const reviewLoopAdvice = (slots) =>
188
193
  ]
189
194
  : [];
190
195
 
196
+ // The grounding pre-step (AD-038, extending the AD-033 verbatim-contract rendering): whenever the
197
+ // resolved dispatch includes agy-review, print the CONCRETE facts-assembly invocation + the
198
+ // --facts form as a copy-paste pre-step — population, not placeholders. Plan-path population rule
199
+ // (the review-state plan-in-flight detector): exactly ONE plan in flight → render it populated;
200
+ // zero or several → the explicit `--plan <path>` placeholder + a one-line discovery caveat. The
201
+ // suggested --out lives OUTSIDE the repo (/tmp) — grounding.mjs refuses a non-scratch destination.
202
+ const GROUNDING_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'grounding.mjs');
203
+ const GROUNDING_FACTS_OUT = '/tmp/review-facts.md';
204
+ const groundingPreStepAdvice = (activity, slots, plans) => {
205
+ if (!slots.some((s) => (s.backends ?? []).includes('agy-review'))) return [];
206
+ const planArg = plans.length === 1 ? `--plan "${PLANS_REL}/${plans[0]}"` : '--plan <path>';
207
+ // plan-authoring reviews the plan FILE — when exactly one plan is in flight, the review command
208
+ // is populated with the same discovered path (codex R3: a known path never renders a placeholder).
209
+ const reviewForm =
210
+ activity === 'plan-authoring'
211
+ ? plans.length === 1
212
+ ? `agy-review plan "${PLANS_REL}/${plans[0]}"`
213
+ : 'agy-review plan <plan-file>'
214
+ : 'agy-review code';
215
+ // `run:`/`then:` prefixes keep these POPULATED command lines machine-distinguishable from the
216
+ // verbatim contract DESCRIPTORS above (the descriptor drift guard set-equals bare wrapper lines).
217
+ // Path arguments are double-quoted — a skill dir or plan name with a space must stay copy-pasteable.
218
+ const lines = [
219
+ 'Grounding pre-step (agy is dispatched — assemble the verified facts BEFORE the review; grounding.mjs slices verbatim, judgment additions stay yours):',
220
+ ` run: node "${GROUNDING_TOOL}" --constraints ${planArg} --out ${GROUNDING_FACTS_OUT}`,
221
+ ` then: ${reviewForm} --facts @${GROUNDING_FACTS_OUT}`,
222
+ ];
223
+ if (plans.length === 0) {
224
+ lines.push(` ↳ plan discovery: no plan in flight under ${PLANS_REL} — substitute the plan file you are reviewing against, or drop --plan for constraints-only facts.`);
225
+ } else if (plans.length > 1) {
226
+ lines.push(` ↳ plan discovery: ${plans.length} plans in flight under ${PLANS_REL} (${plans.join(', ')}) — populate --plan with the one under review.`);
227
+ }
228
+ return lines;
229
+ };
230
+
191
231
  // The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
192
232
  // at the point of use like reviewLoopAdvice paraphrases §9/§4). Rendered UNCONDITIONALLY for
193
233
  // every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
@@ -226,7 +266,7 @@ const contractLines = ({ cmd, contract }) => {
226
266
  return lines;
227
267
  };
228
268
 
229
- const formatHuman = ({ activity, section, slots, warnings }) => {
269
+ const formatHuman = ({ activity, section, slots, warnings, plans }) => {
230
270
  const lines = [
231
271
  section,
232
272
  '',
@@ -238,6 +278,8 @@ const formatHuman = ({ activity, section, slots, warnings }) => {
238
278
  if (s.reason) lines.push(` ↳ ${s.reason}`);
239
279
  for (const c of s.contracts ?? []) lines.push(...contractLines(c));
240
280
  }
281
+ const grounding = groundingPreStepAdvice(activity, slots, plans);
282
+ if (grounding.length) lines.push('', ...grounding);
241
283
  const advice = reviewLoopAdvice(slots);
242
284
  if (advice.length) lines.push('', ...advice);
243
285
  lines.push('', ...costLanesAdvice());
@@ -248,7 +290,7 @@ const formatHuman = ({ activity, section, slots, warnings }) => {
248
290
  return lines.join('\n');
249
291
  };
250
292
 
251
- const buildJson = ({ activity, section, slots, configSource, warnings }) => ({
293
+ const buildJson = ({ activity, section, slots, configSource, warnings, plans }) => ({
252
294
  activity,
253
295
  section,
254
296
  slots: Object.fromEntries(
@@ -257,6 +299,8 @@ const buildJson = ({ activity, section, slots, configSource, warnings }) => ({
257
299
  slots.map((s) => [s.slot, { recipe: s.recipe, source: s.source, degradedFrom: s.degradedFrom, reason: s.reason, backends: s.backends, contracts: s.contracts }]),
258
300
  ),
259
301
  reviewLoop: reviewLoopAdvice(slots),
302
+ // ADDITIVE (AD-038): the populated grounding pre-step, structured (empty when agy is not dispatched).
303
+ groundingPreStep: groundingPreStepAdvice(activity, slots, plans),
260
304
  // ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
261
305
  costLanes: costLanesAdvice(),
262
306
  configSource,
@@ -311,9 +355,10 @@ export const main = (argv, ctx = {}) => {
311
355
  }
312
356
  const slots = resolveAllSlots({ activity, config, detection, overrides });
313
357
  const warnings = [...detectWarnings, ...collectWarnings(slots)];
358
+ const plans = plansInFlight(cwd);
314
359
  const stdout = json
315
- ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings }), null, 2)
316
- : formatHuman({ activity, section, slots, warnings });
360
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans }), null, 2)
361
+ : formatHuman({ activity, section, slots, warnings, plans });
317
362
  return { code: 0, stdout, stderr: '' };
318
363
  } catch (err) {
319
364
  return { code: err.exitCode ?? 1, stdout: '', stderr: `procedures: ${err.message}` };
package/tools/recipes.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  import { pathToFileURL } from 'node:url';
18
18
  import {
19
19
  detectBackends,
20
+ wrapperCmdFor,
20
21
  READY,
21
22
  NEEDS_SKILL,
22
23
  NEEDS_CLI,
@@ -300,6 +301,41 @@ export const composeStatusLine = (detection, recommendation) => {
300
301
  return `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
301
302
  };
302
303
 
304
+ // ── the one-line ACTIVE-recipe line (the discovery line — configured, never recommended) ───────────
305
+
306
+ // composeActiveRecipeLine({ config, source }, detection) → ONE line rendering the CONFIGURED recipe of
307
+ // every activity/slot (resolved via resolveActivityRecipe: config entry, else computed default), each
308
+ // with its source label, its degradation stated, and its dispatched wrapper set — explicitly labeled
309
+ // "configured" and contrasted with the readiness-RECOMMENDED recipe (which composeStatusLine shows and
310
+ // which is NOT what runs). This is the machine-composed sibling of composeStatusLine (AD-034): the
311
+ // session-start checklist + the handover "Active recipes:" slot paste it verbatim, so no agent composes
312
+ // the configured-recipe facts by hand. `{ config, source }` is exactly what loadConfig returns (source
313
+ // 'none' when no config file exists). Always exactly one line: no part may carry a newline (pinned).
314
+ export const composeActiveRecipeLine = ({ config, source } = {}, detection) => {
315
+ const cells = [];
316
+ for (const [activity, def] of Object.entries(ACTIVITIES)) {
317
+ for (const slot of Object.keys(def.slots)) {
318
+ const r = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot });
319
+ const { dispatch } = planRecipe(r.recipe, detection);
320
+ const wrappers = dispatch.map((d) => wrapperCmdFor(d.backend, d.role)).filter(Boolean);
321
+ const srcLabel = r.source === 'config' ? 'configured' : 'computed default';
322
+ const head = r.degradedFrom
323
+ ? `${activity}.${slot} = ${r.degradedFrom} (${srcLabel}; degrades here to ${r.recipe} — ${r.reason})`
324
+ : `${activity}.${slot} = ${r.recipe} (${srcLabel})`;
325
+ const suffix =
326
+ wrappers.length >= 2
327
+ ? ` → every backend every round: ${wrappers.join(' + ')}`
328
+ : wrappers.length === 1
329
+ ? ` → ${wrappers[0]}`
330
+ : '';
331
+ cells.push(`${head}${suffix}`);
332
+ }
333
+ }
334
+ const rec = recommendRecipe(detection);
335
+ const origin = source === 'none' || config == null ? 'no config file — computed defaults apply' : `from ${source}`;
336
+ return `active recipes (${origin}): ${cells.join(' · ')} — the configured recipes above are what runs; readiness-recommended here: ${rec.recipe} (informational)`;
337
+ };
338
+
303
339
  // ── report + CLI ─────────────────────────────────────────────────────────────────
304
340
 
305
341
  // The structured report behind `--json` — the recipes, the recommendation, a plan per recipe, and
@@ -343,38 +379,68 @@ export const formatRecipes = (detection) => {
343
379
  };
344
380
 
345
381
  // The full argv vocabulary — anything else rejects LOUDLY. The old parse silently routed unknown
346
- // args into the multi-line human render; with `--status-line` (whose output is pasted as fact) a
347
- // mistyped flag masquerading as a mode would be a silent failure, so the parse is strict now.
348
- const KNOWN_ARGS = new Set(['--help', '-h', '--json', '--status-line']);
382
+ // args into the multi-line human render; with `--status-line` / `--active-line` (whose output is
383
+ // pasted as fact) a mistyped flag masquerading as a mode would be a silent failure, so the parse is
384
+ // strict now.
385
+ const KNOWN_ARGS = new Set(['--help', '-h', '--json', '--status-line', '--active-line']);
386
+ const EXCLUSIVE_ARGS = ['--json', '--status-line', '--active-line']; // each owns stdout whole
349
387
 
350
- const main = (argv) => {
388
+ const main = async (argv) => {
351
389
  if (argv.includes('--help') || argv.includes('-h')) {
352
390
  console.log(`recipes — read-only orchestration-recipe advisor for the agent-workflow family.
353
391
 
354
392
  Usage:
355
- node recipes.mjs [--json | --status-line]
393
+ node recipes.mjs [--json | --status-line | --active-line]
356
394
 
357
395
  Lists the four recipes (Solo / Reviewed / Council / Delegated) and, from the read-only backend
358
396
  detector, plans + recommends one for the current environment. --status-line prints exactly ONE
359
397
  line — the machine-composed backend-status summary the bootstrap/upgrade reports paste verbatim.
360
- --json emits the structured report (incl. the same line as \`statusLine\`); the two are mutually
398
+ --active-line prints exactly ONE line the CONFIGURED recipe per activity/slot, resolved from the
399
+ per-project docs/ai/orchestration.json (read from the current directory) + live readiness, with
400
+ degradation stated; paste it verbatim at session start / into the handover "Active recipes:" slot.
401
+ --json emits the structured report (incl. the same line as \`statusLine\`); the three are mutually
361
402
  exclusive. Detection only — never writes, never commits, never runs a subscription CLI.`);
362
403
  return;
363
404
  }
364
405
  const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
365
406
  if (unknown !== undefined) {
366
407
  console.error(`[agent-workflow-kit] unknown argument: ${unknown}`);
367
- process.exit(1);
408
+ return 1;
368
409
  }
369
- if (argv.includes('--json') && argv.includes('--status-line')) {
370
- console.error('[agent-workflow-kit] --json and --status-line are mutually exclusive pick one output');
371
- process.exit(1);
410
+ const exclusive = EXCLUSIVE_ARGS.filter((a) => argv.includes(a));
411
+ if (exclusive.length > 1) {
412
+ console.error(`[agent-workflow-kit] ${exclusive.join(' and ')} are mutually exclusive — pick one output`);
413
+ return 1;
372
414
  }
373
415
  const detection = detectBackends();
374
- if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection)));
416
+ if (argv.includes('--active-line')) {
417
+ // Lazy import: orchestration-config.mjs statically imports this module (ACTIVITIES/SLOT_RECIPES),
418
+ // so the config reader is pulled in at run time only — no static import cycle.
419
+ const { loadConfig } = await import('./orchestration-config.mjs');
420
+ try {
421
+ console.log(composeActiveRecipeLine(loadConfig(process.cwd()), detection));
422
+ } catch (err) {
423
+ console.error(`[agent-workflow-kit] ${err.message}`);
424
+ return err.exitCode ?? 1;
425
+ }
426
+ } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection)));
375
427
  else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
376
428
  else console.log(formatRecipes(detection));
429
+ return 0;
377
430
  };
378
431
 
379
432
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
380
- if (isDirectRun) main(process.argv.slice(2));
433
+ // Natural exit via process.exitCode — never process.exit inside the async main (it would drop buffered
434
+ // stdio writes on piped stderr), and never a TOP-LEVEL await here: orchestration-config.mjs statically
435
+ // imports this module, so awaiting the dynamic import during our own evaluation would deadlock the cycle.
436
+ if (isDirectRun) {
437
+ main(process.argv.slice(2)).then(
438
+ (code) => {
439
+ process.exitCode = code ?? 0;
440
+ },
441
+ (err) => {
442
+ console.error(`[agent-workflow-kit] ${(err && err.message) || err}`);
443
+ process.exitCode = 1;
444
+ },
445
+ );
446
+ }
@@ -101,6 +101,12 @@ const renderSettings = (vm, { color, glyph }) => {
101
101
  else if (s.velocity) {
102
102
  lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
103
103
  }
104
+ // gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present.
105
+ if (s.hook?.error) lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}error: ${s.hook.error}`);
106
+ else if (s.hook) {
107
+ const yn = (b) => (b ? 'yes' : 'no');
108
+ lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}wired=${yn(s.hook.wired)} · file=${yn(s.hook.filePlaced)} · gates.json=${yn(s.hook.declarationPresent)}`);
109
+ }
104
110
  return lines;
105
111
  };
106
112
 
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+ // review-state.mjs — the read-only review-receipt checker behind `/agent-workflow-kit review-state`
3
+ // (AD-038). It makes "reviewed ≠ shipped" mechanically detectable: the bridge review wrappers
4
+ // (codex-review / agy-review ≥2.2.0) append one receipt line per successful review; this tool
5
+ // resolves the effective `plan-execution.review` recipe (the advisor's single-source readers),
6
+ // recomputes the CURRENT canonical uncommitted-state fingerprint, and reports — per recipe-named
7
+ // backend — whether a FRESH, grounded, current-fingerprint receipt exists. `--check` turns the
8
+ // report into a gate exit code (declare it in docs/ai/gates.json — by hand; never auto-seeded).
9
+ //
10
+ // Normative `--check` exit contract (the single home of this list — SKILL.md points here):
11
+ // exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there —
12
+ // i.e. no reviewer backend is ready); when no plan is in flight (docs/plans/ holds no
13
+ // top-level .md that is not queue.md and not scratch by the naming convention: prefixes
14
+ // EXECUTE- / FEEDBACK-, or a name containing PROMPT / prompt / handoff); when the tree is
15
+ // clean (nothing to review); when the cwd is not a git work tree (nothing to fingerprint);
16
+ // and when EVERY recipe-named backend has a current-fingerprint receipt with acceptable
17
+ // grounding (fresh:true, artifact "code", grounded:true).
18
+ // exit 1 when a recipe-named backend has no current-fingerprint receipt — including the
19
+ // stale-after-edit case (any tracked/untracked change after the review moves the
20
+ // fingerprint) — or when its only current receipts carry grounded:false (an ungrounded
21
+ // agy review under reviewed/council never satisfies the gate).
22
+ // Informational receipts NEVER satisfy (nor fail) the tree check: plan/diff-mode receipts
23
+ // (artifact ≠ "code") and continuations (fresh:false — agy --continue/--conversation cannot attest
24
+ // a folded tree; only a fresh grounded re-run mints a gate-satisfying receipt).
25
+ //
26
+ // The fingerprint is the ONE canonical uncommitted-state identity — sha256 over: staged diff +
27
+ // unstaged diff + untracked-not-ignored file contents (binary/symlink/non-regular untracked paths
28
+ // ride as name-only notes). Domain == the review-payload domain the wrappers assemble; the prose
29
+ // definition lives in each bridge's capability.json roles.review.contract.receipt, and the bash
30
+ // twin lives in both wrappers — cross-checked by test/review-fingerprint-parity.test.mjs.
31
+ //
32
+ // HUMAN residual (accepted, documented): `git commit --no-verify` skips any pre-commit gate, and
33
+ // deleting/editing the receipt file forges state — receipts live in the git dir (never committable)
34
+ // as an honest self-discipline mechanism, not a security boundary.
35
+ //
36
+ // Read-only: never writes, never commits, never runs a subscription CLI. It DOES spawn `git`
37
+ // (read-only queries) to compute the fingerprint — stated honestly in the catalog. Dependency-free,
38
+ // Node >= 18. No side effects on import (the isDirectRun idiom).
39
+
40
+ import { readFileSync, readdirSync, lstatSync, readlinkSync, openSync, readSync, closeSync } from 'node:fs';
41
+ import { join } from 'node:path';
42
+ import { pathToFileURL } from 'node:url';
43
+ import { spawnSync } from 'node:child_process';
44
+ import { createHash } from 'node:crypto';
45
+ import { detectBackends } from './detect-backends.mjs';
46
+ import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
47
+ import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
48
+
49
+ export const RECEIPTS_BASENAME = 'agent-workflow-review-receipts.jsonl';
50
+ export const PLANS_REL = 'docs/plans';
51
+ const ACTIVITY = 'plan-execution';
52
+ const SLOT = 'review';
53
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff can be large; never truncate silently
54
+
55
+ // ── git plumbing (read-only queries; injectable for tests) ─────────────────────────
56
+
57
+ const gitRaw = (args, cwd) =>
58
+ spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
59
+
60
+ // stdout Buffer of a git query, or null when git fails (not a repo / git absent).
61
+ const gitBuf = (args, cwd) => {
62
+ const r = gitRaw(args, cwd);
63
+ if (r.error || r.status !== 0) return null;
64
+ return r.stdout;
65
+ };
66
+
67
+ const gitLine = (args, cwd) => {
68
+ const buf = gitBuf(args, cwd);
69
+ return buf == null ? null : buf.toString('utf8').replace(/\r?\n$/, '');
70
+ };
71
+
72
+ // ── the canonical fingerprint (node twin of the wrappers' bash implementation) ──────
73
+
74
+ // First 8 KiB contain a NUL byte → binary (git's own heuristic; mirrors the wrappers' is_binary).
75
+ const isBinaryFile = (path) => {
76
+ let fd;
77
+ try {
78
+ fd = openSync(path, 'r');
79
+ const buf = Buffer.alloc(8192);
80
+ const n = readSync(fd, buf, 0, 8192, 0);
81
+ return buf.subarray(0, n).includes(0);
82
+ } catch {
83
+ return false;
84
+ } finally {
85
+ if (fd !== undefined) closeSync(fd);
86
+ }
87
+ };
88
+
89
+ // The canonical payload bytes: staged diff + unstaged diff + the untracked-not-ignored section —
90
+ // byte-identical to the wrappers' emit_fingerprint_payload (same git invocations, same headers,
91
+ // same ls-files ordering), emitted from the work-tree ROOT. Returns null outside a git work tree.
92
+ export const computeFingerprintPayload = (cwd) => {
93
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
94
+ if (top == null) return null;
95
+ const staged = gitBuf(['diff', '--cached', '--no-ext-diff'], top);
96
+ const unstaged = gitBuf(['diff', '--no-ext-diff'], top);
97
+ const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
98
+ if (staged == null || unstaged == null || untrackedZ == null) return null;
99
+ const chunks = [staged, unstaged];
100
+ for (const rel of untrackedZ.toString('utf8').split('\0').filter(Boolean)) {
101
+ const full = join(top, rel);
102
+ let stat = null;
103
+ try {
104
+ stat = lstatSync(full);
105
+ } catch {
106
+ stat = null;
107
+ }
108
+ if (stat?.isSymbolicLink()) {
109
+ let target = '?';
110
+ try {
111
+ target = readlinkSync(full);
112
+ } catch {
113
+ target = '?';
114
+ }
115
+ chunks.push(Buffer.from(`untracked-symlink:${rel} -> ${target}\n`));
116
+ } else if (!stat?.isFile()) {
117
+ chunks.push(Buffer.from(`untracked-nonregular:${rel}\n`));
118
+ } else if (isBinaryFile(full)) {
119
+ chunks.push(Buffer.from(`untracked-binary:${rel}\n`));
120
+ } else {
121
+ chunks.push(Buffer.from(`untracked:${rel}\n`));
122
+ chunks.push(readFileSync(full));
123
+ }
124
+ }
125
+ return Buffer.concat(chunks);
126
+ };
127
+
128
+ // sha256 hex of the canonical payload, or null outside a git work tree.
129
+ export const computeTreeFingerprint = (cwd) => {
130
+ const payload = computeFingerprintPayload(cwd);
131
+ return payload == null ? null : createHash('sha256').update(payload).digest('hex');
132
+ };
133
+
134
+ // Clean = nothing staged, nothing unstaged, no untracked-not-ignored paths (the wrappers' no-diff
135
+ // preflight). null when not decidable (not a git work tree). Anchored at the work-tree ROOT like
136
+ // the fingerprint: `git ls-files --others` is cwd-SCOPED, so a subdirectory invocation would
137
+ // otherwise miss root/sibling untracked paths and report a dirty tree as clean (codex R1 finding).
138
+ export const isTreeClean = (cwd) => {
139
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
140
+ if (top == null) return null;
141
+ const staged = gitRaw(['diff', '--cached', '--quiet'], top);
142
+ const unstaged = gitRaw(['diff', '--quiet'], top);
143
+ if (staged.error || unstaged.error || staged.status > 1 || unstaged.status > 1) return null;
144
+ const untracked = gitBuf(['ls-files', '--others', '--exclude-standard'], top);
145
+ if (untracked == null) return null;
146
+ return staged.status === 0 && unstaged.status === 0 && untracked.toString('utf8').trim() === '';
147
+ };
148
+
149
+ // ── plan-in-flight detector (the AD-038 naming convention; documented in queue.md) ─────
150
+
151
+ // Scratch by the naming convention: EXECUTE-/FEEDBACK- prefixes, or a name carrying PROMPT/prompt/
152
+ // handoff. queue.md is the series index, never a plan.
153
+ export const isScratchPlanName = (name) =>
154
+ name === 'queue.md' ||
155
+ name.startsWith('EXECUTE-') ||
156
+ name.startsWith('FEEDBACK-') ||
157
+ name.includes('PROMPT') ||
158
+ name.includes('prompt') ||
159
+ name.includes('handoff');
160
+
161
+ // The in-flight plan files: top-level docs/plans/*.md minus queue.md minus scratch. [] when the
162
+ // directory is absent (no plans → nothing in flight).
163
+ export const plansInFlight = (cwd, readdir = readdirSync) => {
164
+ let entries;
165
+ try {
166
+ entries = readdir(join(cwd, PLANS_REL), { withFileTypes: true });
167
+ } catch {
168
+ return [];
169
+ }
170
+ return entries
171
+ .filter((e) => e.isFile() && e.name.endsWith('.md') && !isScratchPlanName(e.name))
172
+ .map((e) => e.name)
173
+ .sort();
174
+ };
175
+
176
+ // ── receipts ───────────────────────────────────────────────────────────────────────
177
+
178
+ export const resolveReceiptsPath = (cwd, env = process.env) => {
179
+ if (env.AW_REVIEW_RECEIPTS) return env.AW_REVIEW_RECEIPTS;
180
+ const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
181
+ return gitDir == null ? null : join(gitDir, RECEIPTS_BASENAME);
182
+ };
183
+
184
+ // Parse the receipt file → { receipts, malformed }. Absent file → empty (not an error: no review
185
+ // ever ran). A malformed line is counted + reported, never silently dropped.
186
+ export const readReceipts = (path, readFile = readFileSync) => {
187
+ let raw;
188
+ try {
189
+ raw = readFile(path, 'utf8');
190
+ } catch {
191
+ return { receipts: [], malformed: 0 };
192
+ }
193
+ const receipts = [];
194
+ let malformed = 0;
195
+ for (const line of raw.split('\n')) {
196
+ if (line.trim() === '') continue;
197
+ try {
198
+ const parsed = JSON.parse(line);
199
+ if (parsed && typeof parsed === 'object' && typeof parsed.backend === 'string') receipts.push(parsed);
200
+ else malformed += 1;
201
+ } catch {
202
+ malformed += 1;
203
+ }
204
+ }
205
+ return { receipts, malformed };
206
+ };
207
+
208
+ // A receipt that can satisfy the tree check: a FRESH code-mode receipt for the current fingerprint.
209
+ // Plan/diff-mode receipts and continuations are informational-only (see the header contract).
210
+ const satisfies = (receipt, fingerprint) =>
211
+ receipt.fresh === true && receipt.artifact === 'code' && receipt.fingerprint === fingerprint;
212
+
213
+ // Per-backend receipt status for the current fingerprint:
214
+ // current — a satisfying receipt with grounded:true exists (its latest verdict reported);
215
+ // ungrounded — current-fingerprint fresh receipts exist but every one carries grounded:false;
216
+ // stale — this backend has receipts, none for the current fingerprint (edited after review);
217
+ // missing — no usable receipt from this backend at all.
218
+ export const backendReceiptStatus = (receipts, backend, fingerprint) => {
219
+ const own = receipts.filter((r) => r.backend === backend);
220
+ const current = own.filter((r) => satisfies(r, fingerprint));
221
+ const grounded = current.filter((r) => r.grounded === true);
222
+ if (grounded.length > 0) {
223
+ const latest = grounded[grounded.length - 1];
224
+ return { state: 'current', verdict: latest.verdict ?? 'unknown', grounded: true, timestamp: latest.timestamp ?? null };
225
+ }
226
+ if (current.length > 0) {
227
+ const latest = current[current.length - 1];
228
+ return { state: 'ungrounded', verdict: latest.verdict ?? 'unknown', grounded: false, timestamp: latest.timestamp ?? null };
229
+ }
230
+ return { state: own.length > 0 ? 'stale' : 'missing', verdict: null, grounded: null, timestamp: null };
231
+ };
232
+
233
+ // ── the check + report core ─────────────────────────────────────────────────────────
234
+
235
+ // buildState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges.
236
+ // EVERY project-relative read (orchestration config, docs/plans, receipts) anchors at the git
237
+ // work-tree ROOT when one exists — the fingerprint is root-anchored, so a subdirectory invocation
238
+ // must read the same config/plans or a dirty unreceipted tree could false-PASS as "no plan in
239
+ // flight" (codex R1 finding). Outside a git tree the cwd is the only anchor (and --check exits 0).
240
+ export const buildState = ({ cwd, env = process.env, detect = detectBackends } = {}) => {
241
+ const root = gitLine(['rev-parse', '--show-toplevel'], cwd) ?? cwd;
242
+ const { config, source: configSource } = loadConfig(root);
243
+ let detection = [];
244
+ let detectionWarning = null;
245
+ try {
246
+ detection = detect();
247
+ } catch (err) {
248
+ detectionWarning = `backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; the review recipe floors at solo.`;
249
+ }
250
+ const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
251
+ const { dispatch } = planRecipe(resolved.recipe, detection);
252
+ const requiredBackends = dispatch.map((d) => DISPLAY_ALIASES[d.backend] ?? d.backend);
253
+ const plans = plansInFlight(root);
254
+ const fingerprint = computeTreeFingerprint(cwd);
255
+ const clean = fingerprint == null ? null : isTreeClean(cwd);
256
+ const receiptsPath = resolveReceiptsPath(cwd, env);
257
+ const { receipts, malformed } = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
258
+ const backends = requiredBackends.map((b) => ({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }));
259
+ return {
260
+ resolved,
261
+ configSource,
262
+ requiredBackends,
263
+ backends,
264
+ plans,
265
+ fingerprint,
266
+ clean,
267
+ receiptsPath,
268
+ receiptCount: receipts.length,
269
+ malformed,
270
+ detectionWarning,
271
+ };
272
+ };
273
+
274
+ // The normative --check decision (the header contract, in order). → { code, reason }.
275
+ export const decideCheck = (state) => {
276
+ // A DETECTOR FAILURE is unknown state, not "no reviewer ready" — the advisory tools warn and
277
+ // floor at solo, but a GATE must fail closed (codex R2+R3 findings): a broken detector would
278
+ // otherwise disable the receipt requirement both for a configured non-solo recipe AND for a
279
+ // default-config project (whose computed default would be `reviewed` had a backend been ready —
280
+ // unknowable while the detector is down). The ONLY detector-independent green is an EXPLICIT
281
+ // configured solo (that project asked for no reviewer, readiness is irrelevant to it).
282
+ const explicitSolo = state.resolved.recipe === 'solo' && state.resolved.source === 'config' && !state.resolved.degradedFrom;
283
+ if (state.detectionWarning && !explicitSolo) {
284
+ return { code: 1, reason: `cannot verify receipts — ${state.detectionWarning}` };
285
+ }
286
+ if (state.resolved.recipe === 'solo') {
287
+ const why = state.resolved.degradedFrom
288
+ ? `resolved ${ACTIVITY}.${SLOT} recipe degrades to solo here (${state.resolved.reason})`
289
+ : `resolved ${ACTIVITY}.${SLOT} recipe is solo`;
290
+ return { code: 0, reason: `${why} — no receipt required` };
291
+ }
292
+ if (state.plans.length === 0) return { code: 0, reason: 'no plan in flight (docs/plans/ holds no active plan) — no receipt required' };
293
+ if (state.fingerprint == null) return { code: 0, reason: 'not a git work tree — nothing to fingerprint' };
294
+ if (state.clean === true) return { code: 0, reason: 'the working tree is clean — nothing to review' };
295
+ // A malformed receipt line is never silently ignored (No-silent-failures Hard Constraint): it
296
+ // cannot fail the gate by itself (a forged/corrupt line must not brick commits), but the check
297
+ // line always names it so a PASS over a partially-corrupt file is visible.
298
+ const malformedNote = state.malformed > 0 ? ` — ${state.malformed} malformed receipt line(s) ignored; inspect ${state.receiptsPath}` : '';
299
+ const failing = state.backends.filter((b) => b.state !== 'current');
300
+ if (failing.length === 0) {
301
+ return { code: 0, reason: `every recipe-named backend has a fresh grounded receipt for the current tree (${state.requiredBackends.join(' + ')})${malformedNote}` };
302
+ }
303
+ const parts = failing.map((b) => {
304
+ if (b.state === 'ungrounded') return `${b.backend}: only ungrounded receipts for the current tree — re-run grounded (--facts)`;
305
+ if (b.state === 'stale') return `${b.backend}: receipts exist but none matches the current tree (edited after review) — run a fresh review`;
306
+ return `${b.backend}: no receipt — run its review wrapper`;
307
+ });
308
+ return { code: 1, reason: `${parts.join('; ')}${malformedNote}` };
309
+ };
310
+
311
+ // ── rendering ───────────────────────────────────────────────────────────────────────
312
+
313
+ const STATE_GLYPH = { current: '✓', ungrounded: '✗', stale: '✗', missing: '✗' };
314
+
315
+ const formatHuman = (state, check) => {
316
+ const src = state.resolved.source === 'config' ? `from ${CONFIG_REL}` : 'computed default';
317
+ const lines = [
318
+ `review-state — ${ACTIVITY}.${SLOT} = ${state.resolved.recipe} (${src})${state.requiredBackends.length ? ` → ${state.requiredBackends.join(' + ')}` : ''}`,
319
+ ];
320
+ if (state.detectionWarning) lines.push(` ⚠ ${state.detectionWarning}`);
321
+ lines.push(` plan in flight: ${state.plans.length ? state.plans.join(', ') : '(none)'}`);
322
+ if (state.fingerprint == null) lines.push(' tree: not a git work tree');
323
+ else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
324
+ else lines.push(` tree fingerprint: ${state.fingerprint}`);
325
+ lines.push(` receipts: ${state.receiptsPath ?? '(unresolvable — no git dir)'} (${state.receiptCount} line(s)${state.malformed ? `, ${state.malformed} malformed — inspect the file` : ''})`);
326
+ for (const b of state.backends) {
327
+ const detail =
328
+ b.state === 'current'
329
+ ? `current (verdict: ${b.verdict}, grounded, ${b.timestamp ?? '?'})`
330
+ : b.state === 'ungrounded'
331
+ ? `ungrounded for the current tree (verdict: ${b.verdict}) — a grounded fresh run is required`
332
+ : b.state === 'stale'
333
+ ? 'stale — no receipt matches the current tree (edited after review)'
334
+ : 'missing — no receipt from this backend';
335
+ lines.push(` ${STATE_GLYPH[b.state]} ${b.backend}: ${detail}`);
336
+ }
337
+ lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
338
+ return lines.join('\n');
339
+ };
340
+
341
+ const HELP = `review-state — read-only review-receipt checker (agent-workflow family, AD-038).
342
+
343
+ Usage:
344
+ node review-state.mjs [--check] [--json]
345
+
346
+ Resolves the effective ${ACTIVITY}.${SLOT} recipe (${CONFIG_REL} + the read-only backend detector),
347
+ recomputes the canonical uncommitted-state fingerprint (staged + unstaged + untracked-not-ignored —
348
+ the review-payload domain), reads the receipt file the review wrappers append to
349
+ (<git dir>/${RECEIPTS_BASENAME}; AW_REVIEW_RECEIPTS overrides), and reports per-backend receipt
350
+ presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and continuations
351
+ (fresh:false) are informational-only — they never satisfy the tree check.
352
+
353
+ --check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
354
+ a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded; 1 when a
355
+ recipe-named backend is missing, stale (edited after review), or grounded:false under
356
+ reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json) — never auto-seeded.
357
+
358
+ Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
359
+ Human residual: git commit --no-verify and receipt-file deletion remain possible — this is a
360
+ self-discipline mechanism, not a security boundary.
361
+
362
+ Exit codes: 0 pass (or plain report); 1 check failed or config error (loud); 2 usage.`;
363
+
364
+ const KNOWN_ARGS = new Set(['--help', '-h', '--check', '--json']);
365
+
366
+ export const main = (argv, ctx = {}) => {
367
+ const cwd = ctx.cwd ?? process.cwd();
368
+ const env = ctx.env ?? process.env;
369
+ const detect = ctx.detect ?? detectBackends;
370
+ try {
371
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
372
+ const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
373
+ if (unknown !== undefined) throw fail(2, `unknown argument: ${unknown}`);
374
+ const state = buildState({ cwd, env, detect });
375
+ const check = decideCheck(state);
376
+ if (argv.includes('--json')) {
377
+ return { code: argv.includes('--check') ? check.code : 0, stdout: JSON.stringify({ ...state, check }, null, 2), stderr: '' };
378
+ }
379
+ if (argv.includes('--check')) {
380
+ return { code: check.code, stdout: `review-state check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`, stderr: '' };
381
+ }
382
+ return { code: 0, stdout: formatHuman(state, check), stderr: '' };
383
+ } catch (err) {
384
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `review-state: ${err.message}` };
385
+ }
386
+ };
387
+
388
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
389
+ if (isDirectRun) {
390
+ const r = main(process.argv.slice(2));
391
+ // Exact writes + a natural exit: process.exit() can truncate unflushed piped stdio (codex R2).
392
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
393
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
394
+ process.exitCode = r.code;
395
+ }