@sabaiway/agent-workflow-kit 1.29.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.
@@ -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
+ }
@@ -25,7 +25,7 @@ import { readFileSync, lstatSync } from 'node:fs';
25
25
  import { homedir } from 'node:os';
26
26
  import { pathToFileURL } from 'node:url';
27
27
  import { detectBackends } from './detect-backends.mjs';
28
- import { resolveActivityRecipe } from './recipes.mjs';
28
+ import { resolveActivityRecipe, composeActiveRecipeLine } from './recipes.mjs';
29
29
  import {
30
30
  CONFIG_REL,
31
31
  fail,
@@ -91,7 +91,7 @@ const effectiveLine = (e) =>
91
91
  ? `effective here: ${e.effective} (requested ${e.degradedFrom} → degraded: ${e.reason})`
92
92
  : `effective here: ${e.effective}`;
93
93
 
94
- const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody }) => {
94
+ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody, activeLine }) => {
95
95
  const lines = [];
96
96
  if (wrote) lines.push(`wrote ${CONFIG_REL}`);
97
97
  else if (changed.length) lines.push(`set-recipe — preview (nothing written; re-run with --write to apply)`);
@@ -102,6 +102,12 @@ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody
102
102
  for (const e of unchanged) lines.push(` ${e.activity}.${e.slot}: already ${valueLabel(e.from)} (no change)`);
103
103
  for (const w of warnings) lines.push(` ⚠ ${w}`);
104
104
  if (wrote && fileBody) lines.push('', `${CONFIG_REL} now reads:`, fileBody.replace(/\n$/, ''));
105
+ // The post-write discovery echo (AD-038): after every successful write, paste the freshly composed
106
+ // active-recipe line verbatim + the one-line handover reminder — the writer is the ONE surface that
107
+ // changes the config, so the change is never announced anywhere else.
108
+ if (wrote && activeLine) {
109
+ lines.push('', activeLine, `refresh the "Active recipes:" slot line in docs/ai/handover.md with the line above.`);
110
+ }
105
111
  if (!wrote) {
106
112
  if (!changed.length) lines.push(' no changes — nothing to write.');
107
113
  else if (willWrite) lines.push('', `would write ${CONFIG_REL} — re-run with --write to apply.`);
@@ -109,12 +115,15 @@ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody
109
115
  return lines.join('\n');
110
116
  };
111
117
 
112
- const buildJson = ({ changed, unchanged, warnings, writtenPath, noop }) => ({
118
+ const buildJson = ({ changed, unchanged, warnings, writtenPath, noop, activeLine }) => ({
113
119
  changed: changed.map((e) => ({ activity: e.activity, slot: e.slot, from: e.from, to: e.to, effective: e.effective, degradedFrom: e.degradedFrom ?? null, reason: e.reason ?? null })),
114
120
  unchanged: unchanged.map((e) => ({ activity: e.activity, slot: e.slot, recipe: e.from })),
115
121
  writtenPath: writtenPath ?? null,
116
122
  noop,
117
123
  warnings,
124
+ // ADDITIVE: the machine-composed active-recipe line, present only after a successful write (the
125
+ // human render pastes the same line) — the output stays one parseable JSON object either way.
126
+ activeLine: activeLine ?? null,
118
127
  });
119
128
 
120
129
  const HELP = `set-recipe — write the per-project orchestration config (docs/ai/orchestration.json).
@@ -199,9 +208,10 @@ export const main = (argv, ctx = {}) => {
199
208
  validateConfig(after); // defensive re-validate immediately before the write
200
209
  const { writtenPath } = writeConfig(cwd, after, ctx);
201
210
  const fileBody = serializeConfig(after);
211
+ const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, detection);
202
212
  const stdout = json
203
- ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false }), null, 2)
204
- : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody });
213
+ ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false, activeLine }), null, 2)
214
+ : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine });
205
215
  return { code: 0, stdout, stderr: '' };
206
216
  } catch (err) {
207
217
  return { code: err.exitCode ?? 1, stdout: '', stderr: `set-recipe: ${err.message}` };