mandrel 2.13.0 → 2.15.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 (71) hide show
  1. package/.agents/audit-checklists/navigability.md +1 -1
  2. package/.agents/docs/workflows.md +4 -5
  3. package/.agents/schemas/story-deliver-terminal.schema.json +60 -6
  4. package/.agents/scripts/acceptance-eval.js +18 -1
  5. package/.agents/scripts/agents-bootstrap-github.js +22 -1
  6. package/.agents/scripts/apply-quality-bootstrap.js +6 -0
  7. package/.agents/scripts/audit-labels-bootstrap.js +15 -1
  8. package/.agents/scripts/audit-to-stories.js +26 -1
  9. package/.agents/scripts/boot-sweep.js +4 -1
  10. package/.agents/scripts/bootstrap.js +1 -0
  11. package/.agents/scripts/check-arch-cycles.js +20 -0
  12. package/.agents/scripts/check-baselines.js +8 -2
  13. package/.agents/scripts/check-context-budget.js +40 -5
  14. package/.agents/scripts/check-dead-exports.js +21 -0
  15. package/.agents/scripts/check-doc-links.js +12 -1
  16. package/.agents/scripts/check-lifecycle-doc-drift.js +9 -0
  17. package/.agents/scripts/check-workflow-citations.js +332 -0
  18. package/.agents/scripts/deliver-light.js +71 -9
  19. package/.agents/scripts/deliver-recover.js +4 -1
  20. package/.agents/scripts/diagnose-friction.js +17 -1
  21. package/.agents/scripts/diagnose.js +20 -14
  22. package/.agents/scripts/drain-pending-cleanup.js +20 -1
  23. package/.agents/scripts/evidence-gate.js +20 -1
  24. package/.agents/scripts/generate-config-docs.js +14 -1
  25. package/.agents/scripts/generate-lifecycle-docs.js +14 -1
  26. package/.agents/scripts/generate-workflows-doc.js +14 -1
  27. package/.agents/scripts/git-cleanup.js +32 -1
  28. package/.agents/scripts/lib/cli-usage.js +174 -0
  29. package/.agents/scripts/lib/cli-utils.js +12 -0
  30. package/.agents/scripts/lib/doc-tiers.js +53 -10
  31. package/.agents/scripts/lib/orchestration/plan-context.js +16 -11
  32. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +122 -6
  33. package/.agents/scripts/lib/workflow-closure.js +431 -0
  34. package/.agents/scripts/mandrel-update-preflight.js +9 -0
  35. package/.agents/scripts/nav-registry-diff.js +13 -0
  36. package/.agents/scripts/plan-context.js +17 -1
  37. package/.agents/scripts/plan-critics.js +10 -0
  38. package/.agents/scripts/plan-persist.js +33 -1
  39. package/.agents/scripts/plan-run-epilogue.js +12 -1
  40. package/.agents/scripts/quality-preview.js +17 -1
  41. package/.agents/scripts/resolve-doc-tiers.js +13 -0
  42. package/.agents/scripts/resolve-stories.js +1 -0
  43. package/.agents/scripts/resync-status-column.js +4 -1
  44. package/.agents/scripts/signals-view.js +11 -0
  45. package/.agents/scripts/single-story-close.js +24 -0
  46. package/.agents/scripts/single-story-confirm-merge.js +16 -0
  47. package/.agents/scripts/single-story-init.js +21 -1
  48. package/.agents/scripts/stories-wave-tick.js +1 -0
  49. package/.agents/scripts/sync-agentrc.js +16 -4
  50. package/.agents/scripts/update-ticket-state.js +23 -2
  51. package/.agents/workflows/audit-navigability.md +2 -2
  52. package/.agents/workflows/audit-to-stories.md +1 -1
  53. package/.agents/workflows/deliver.md +80 -81
  54. package/.agents/workflows/git-cleanup.md +9 -14
  55. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -13
  56. package/.agents/workflows/helpers/audit-lens-core.md +2 -2
  57. package/.agents/workflows/helpers/code-review.md +11 -11
  58. package/.agents/workflows/helpers/deliver-digest.md +7 -8
  59. package/.agents/workflows/helpers/deliver-light.md +196 -0
  60. package/.agents/workflows/helpers/deliver-reference.md +46 -14
  61. package/.agents/workflows/helpers/deliver-story-reference.md +55 -63
  62. package/.agents/workflows/helpers/deliver-story.md +22 -22
  63. package/.agents/workflows/helpers/mandrel-sync-config.md +2 -2
  64. package/.agents/workflows/helpers/parallel-tooling.md +1 -2
  65. package/.agents/workflows/helpers/plan-reference.md +72 -14
  66. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -4
  67. package/.agents/workflows/mandrel-update.md +6 -6
  68. package/.agents/workflows/plan.md +86 -83
  69. package/docs/CHANGELOG.md +22 -0
  70. package/package.json +2 -1
  71. package/.agents/workflows/deliver-light.md +0 -117
@@ -0,0 +1,332 @@
1
+ /**
2
+ * CLI: ratchet-down gate on provenance citations in workflow prose.
3
+ *
4
+ * Workflow documents ride resident in an executing agent's context. A
5
+ * `(Story #1234)` aside costs the same tokens as instruction and teaches
6
+ * the model Mandrel's own history instead of the task in front of it. The
7
+ * citations that survive are the ones a reader must follow to act; the
8
+ * rest belong in the commit trail and `docs/decisions.md`.
9
+ *
10
+ * Nothing stops the tax re-accumulating one well-meaning aside at a time,
11
+ * so this gate holds the line: count every issue-shaped reference across
12
+ * `.agents/workflows/**\/*.md` and fail when the total rises above the
13
+ * committed baseline at `baselines/workflow-citations.json`.
14
+ *
15
+ * The counted token is the bare `#NNNN` form rather than the
16
+ * `(Story|Epic|issue|refs) #NNNN` phrasing, because the same citation is
17
+ * written both ways — `(Story #4593)` and a bare `(#4593)` — and a gate
18
+ * that only saw the prefixed form would wave the other one through.
19
+ *
20
+ * Ratchet semantics mirror `check-arch-cycles.js`:
21
+ * - Total above the baseline → exit 1, with the files that grew.
22
+ * - Total below the baseline → printed as shrinkage, exit 0. Refresh the
23
+ * baseline with `--update` to lock the reduction in.
24
+ * - Equal → exit 0.
25
+ *
26
+ * Flags:
27
+ * --baseline <path> override the baseline path (default
28
+ * `baselines/workflow-citations.json`, resolved from cwd)
29
+ * --root <path> scan a different workflow root (default
30
+ * `.agents/workflows`)
31
+ * --update rewrite the baseline from the live count
32
+ * --json write the structured envelope to stdout
33
+ */
34
+
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import process from 'node:process';
38
+ import { runAsCli } from './lib/cli-utils.js';
39
+
40
+ /** Default workflow root, relative to the repository root. */
41
+ const DEFAULT_ROOT = path.join('.agents', 'workflows');
42
+
43
+ /** Default baseline path, relative to the repository root. */
44
+ const DEFAULT_BASELINE = path.join('baselines', 'workflow-citations.json');
45
+
46
+ /**
47
+ * Issue-shaped reference: a `#` followed by 3–5 digits. Narrow enough to
48
+ * skip markdown headings and anchor links, wide enough to catch both the
49
+ * `(Story #1234)` and bare `(#1234)` spellings of one citation.
50
+ */
51
+ const CITATION_RE = /#\d{3,5}\b/g;
52
+
53
+ /**
54
+ * Parse argv. Exported so unit tests can pin the parser.
55
+ *
56
+ * @param {string[]} argv
57
+ * @returns {{ baselinePath: string | null, rootPath: string | null, update: boolean, json: boolean }}
58
+ */
59
+ export function parseArgv(argv = []) {
60
+ const out = {
61
+ baselinePath: null,
62
+ rootPath: null,
63
+ update: false,
64
+ json: false,
65
+ };
66
+ for (let i = 0; i < argv.length; i += 1) {
67
+ const flag = argv[i];
68
+ const next = argv[i + 1];
69
+ const takesValue = next && !next.startsWith('--');
70
+ if (flag === '--baseline' && takesValue) {
71
+ out.baselinePath = next;
72
+ i += 1;
73
+ } else if (flag === '--root' && takesValue) {
74
+ out.rootPath = next;
75
+ i += 1;
76
+ } else if (flag === '--update') {
77
+ out.update = true;
78
+ } else if (flag === '--json') {
79
+ out.json = true;
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Recursively collect `.md` files under `rootDir`. Returns absolute paths,
87
+ * sorted for determinism.
88
+ *
89
+ * @param {string} rootDir
90
+ * @returns {string[]}
91
+ */
92
+ export function collectMarkdownFiles(rootDir) {
93
+ const out = [];
94
+ const walk = (dir) => {
95
+ let entries;
96
+ try {
97
+ entries = fs.readdirSync(dir, { withFileTypes: true });
98
+ } catch {
99
+ return;
100
+ }
101
+ for (const entry of entries) {
102
+ const full = path.join(dir, entry.name);
103
+ if (entry.isDirectory()) walk(full);
104
+ else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
105
+ }
106
+ };
107
+ walk(rootDir);
108
+ return out.sort();
109
+ }
110
+
111
+ /**
112
+ * Pure helper: count citations in source text.
113
+ *
114
+ * @param {string} source
115
+ * @returns {number}
116
+ */
117
+ export function countCitations(source) {
118
+ return (String(source ?? '').match(CITATION_RE) ?? []).length;
119
+ }
120
+
121
+ /**
122
+ * Count citations across a file set, relativizing paths against `cwd` so
123
+ * the report serializes identically on every platform. Files with zero
124
+ * citations are omitted — the baseline records where the tax lives.
125
+ *
126
+ * @param {string[]} files absolute paths
127
+ * @param {string} cwd
128
+ * @param {{ readFile?: (p: string) => string }} [opts]
129
+ * @returns {{ total: number, files: Array<{ path: string, count: number }> }}
130
+ */
131
+ export function tallyCitations(files, cwd, { readFile } = {}) {
132
+ const read = readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
133
+ const rows = [];
134
+ let total = 0;
135
+ for (const file of files) {
136
+ let count;
137
+ try {
138
+ count = countCitations(read(file));
139
+ } catch {
140
+ continue;
141
+ }
142
+ if (count === 0) continue;
143
+ total += count;
144
+ rows.push({
145
+ path: path.relative(cwd, file).split(path.sep).join('/'),
146
+ count,
147
+ });
148
+ }
149
+ return { total, files: rows.sort((a, b) => a.path.localeCompare(b.path)) };
150
+ }
151
+
152
+ /**
153
+ * Pure helper: read the baseline envelope. Returns `null` when the file is
154
+ * missing or unparseable — the baseline is not distributed to consumers, so
155
+ * its absence degrades to "no ceiling" rather than a false failure.
156
+ *
157
+ * @param {string} baselinePath
158
+ * @returns {{ total?: number, files?: Array<{ path: string, count: number }> } | null}
159
+ */
160
+ export function loadBaseline(baselinePath) {
161
+ try {
162
+ const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
163
+ return parsed && typeof parsed === 'object' ? parsed : null;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Pure helper: diff the live tally against the baseline. `grew` names the
171
+ * files whose per-file count rose, so a failure points at the edit rather
172
+ * than only at the total.
173
+ *
174
+ * @param {{ total?: number, files?: Array<{ path: string, count: number }> } | null} baseline
175
+ * @param {{ total: number, files: Array<{ path: string, count: number }> }} tally
176
+ * @returns {{ baselineTotal: number | null, delta: number | null, grew: Array<{ path: string, from: number, to: number }> }}
177
+ */
178
+ export function diffTally(baseline, tally) {
179
+ const baselineTotal =
180
+ typeof baseline?.total === 'number' ? baseline.total : null;
181
+ const prior = new Map(
182
+ (baseline?.files ?? []).map((row) => [row.path, row.count]),
183
+ );
184
+ const grew = tally.files
185
+ .map((row) => ({
186
+ path: row.path,
187
+ from: prior.get(row.path) ?? 0,
188
+ to: row.count,
189
+ }))
190
+ .filter((row) => row.to > row.from);
191
+ return {
192
+ baselineTotal,
193
+ delta: baselineTotal === null ? null : tally.total - baselineTotal,
194
+ grew,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Pure helper: render the human-readable diff, mirroring the `+` / `-`
200
+ * vocabulary of the sibling ratchets.
201
+ *
202
+ * @param {{ baselineTotal: number | null, delta: number | null, grew: Array<{ path: string, from: number, to: number }> }} diff
203
+ * @param {{ total: number }} tally
204
+ * @returns {string}
205
+ */
206
+ export function renderDiff(diff, tally) {
207
+ const lines = [];
208
+ for (const row of diff.grew) {
209
+ lines.push(`+ ${row.path}: ${row.from} -> ${row.to}`);
210
+ }
211
+ if (diff.delta !== null && diff.delta < 0) {
212
+ lines.push(
213
+ `[workflow-citations] ⚠ ${-diff.delta} citation(s) below baseline — refresh with --update to lock the reduction in`,
214
+ );
215
+ }
216
+ const tag = diff.delta !== null && diff.delta > 0 ? '(gate fail)' : '(ok)';
217
+ lines.push(
218
+ `[workflow-citations] total=${tally.total} baseline=${diff.baselineTotal ?? 'none'} ${tag}`,
219
+ );
220
+ return lines.join('\n');
221
+ }
222
+
223
+ /**
224
+ * Top-level CLI entry. Exported so tests can drive the pipeline against a
225
+ * tmpdir fixture corpus.
226
+ *
227
+ * @param {{
228
+ * argv?: string[],
229
+ * cwd?: string,
230
+ * stdout?: { write: (s: string) => void },
231
+ * stderr?: { write: (s: string) => void },
232
+ * }} [opts]
233
+ * @returns {Promise<number>} 0 = at or below baseline; 1 = regression
234
+ */
235
+ export async function runCli({
236
+ argv = process.argv.slice(2),
237
+ cwd = process.cwd(),
238
+ stdout = process.stdout,
239
+ stderr = process.stderr,
240
+ } = {}) {
241
+ const { baselinePath, rootPath, update, json } = parseArgv(argv);
242
+ const root = path.resolve(cwd, rootPath ?? DEFAULT_ROOT);
243
+ const resolvedBaselinePath = path.resolve(
244
+ cwd,
245
+ baselinePath ?? DEFAULT_BASELINE,
246
+ );
247
+ if (!fs.existsSync(root)) {
248
+ throw new Error(`[workflow-citations] workflow root not found: ${root}`);
249
+ }
250
+
251
+ const tally = tallyCitations(collectMarkdownFiles(root), cwd);
252
+
253
+ if (update) {
254
+ const envelope = {
255
+ $schema: 'https://mandrel.dev/baselines/workflow-citations.schema.json',
256
+ generatedAt: new Date().toISOString(),
257
+ total: tally.total,
258
+ files: tally.files,
259
+ };
260
+ fs.mkdirSync(path.dirname(resolvedBaselinePath), { recursive: true });
261
+ fs.writeFileSync(
262
+ resolvedBaselinePath,
263
+ `${JSON.stringify(envelope, null, 2)}\n`,
264
+ );
265
+ stdout.write(
266
+ `[workflow-citations] baseline written: ${resolvedBaselinePath} (total=${tally.total})\n`,
267
+ );
268
+ return 0;
269
+ }
270
+
271
+ const baseline = loadBaseline(resolvedBaselinePath);
272
+ const diff = diffTally(baseline, tally);
273
+ const exitCode = diff.delta !== null && diff.delta > 0 ? 1 : 0;
274
+
275
+ if (json) {
276
+ stdout.write(
277
+ `${JSON.stringify(
278
+ {
279
+ kind: 'workflow-citations-report',
280
+ root,
281
+ baselinePath: resolvedBaselinePath,
282
+ total: tally.total,
283
+ baselineTotal: diff.baselineTotal,
284
+ delta: diff.delta,
285
+ files: tally.files,
286
+ grew: diff.grew,
287
+ exitCode,
288
+ },
289
+ null,
290
+ 2,
291
+ )}\n`,
292
+ );
293
+ return exitCode;
294
+ }
295
+
296
+ if (!baseline) {
297
+ stderr.write(
298
+ `[workflow-citations] ⚠ baseline not found at ${resolvedBaselinePath} — no ceiling enforced\n`,
299
+ );
300
+ }
301
+ stdout.write(`\n--- workflow-citations preview ---\n`);
302
+ stdout.write(`${renderDiff(diff, tally)}\n`);
303
+ return exitCode;
304
+ }
305
+
306
+ async function main() {
307
+ return runCli();
308
+ }
309
+
310
+ runAsCli(import.meta.url, main, {
311
+ source: 'workflow-citations',
312
+ propagateExitCode: true,
313
+ errorPrefix: '[workflow-citations] ❌ Fatal error',
314
+ usage: {
315
+ invocation:
316
+ 'node .agents/scripts/check-workflow-citations.js [--baseline <path>] [--root <dir>] [--update] [--json]',
317
+ summary:
318
+ 'Ratchet on provenance citations in workflow prose: count issue-shaped references across .agents/workflows/** and fail when the total rises above the recorded baseline.',
319
+ flags: [
320
+ [
321
+ '--baseline <path>',
322
+ 'Baseline file (default: baselines/workflow-citations.json).',
323
+ ],
324
+ ['--root <dir>', 'Workflow root to scan (default: .agents/workflows).'],
325
+ ['--update', 'Rewrite the baseline from the live count.'],
326
+ ['--json', 'Emit the comparison envelope as JSON.'],
327
+ ],
328
+ notes: [
329
+ 'Exit codes:\n 0 at or below the baseline\n 1 the citation total regressed above the baseline',
330
+ ],
331
+ },
332
+ });
@@ -23,12 +23,25 @@
23
23
  * - **gate** (default) — judge a prompt's predicted footprint. On
24
24
  * `proceed-light` it authors the receipt Story (via the plan-persist
25
25
  * `createStoryIssues` surface) and prints the init/close hand-off. On
26
- * over-scope it prints `ask-operator` (attended) or `escalate-plan`
27
- * (`--yes`), never landing silently.
26
+ * over-scope it prints `ask-operator` (attended) or emits an `escalated`
27
+ * terminal envelope (`--yes`), never landing silently.
28
28
  * - **backstop** (`--backstop --story <id>`) — re-check the ACTUAL diff of
29
29
  * the Story branch after implementation; exit non-zero when it exceeds the
30
30
  * light ceilings, so an over-scope diff is blocked rather than landed.
31
31
  *
32
+ * ## Escalation is terminal, not advisory (Story #4746)
33
+ *
34
+ * Over-scope under `--yes` emits a schema-validated `story-deliver-terminal`
35
+ * envelope with status `escalated` and **ends the session**. Before that it was
36
+ * an ordinary gate envelope plus exit 2 — a warning a caller could walk past,
37
+ * and one mandrel-bench 2.13.0 light-arm run did exactly that: it read the
38
+ * escalation, invoked `/plan` in the same session, and delivered. In-session
39
+ * planning under-decomposed (ONE Story against the scenario's 3-5 contract,
40
+ * where a fresh `/plan` session on the identical seed authored four), so
41
+ * escalation silently produced the outcome the guard exists to prevent.
42
+ * {@link module:lib/orchestration/story-deliver-terminal.buildEscalationTerminal}
43
+ * carries the guarantees the schema then enforces.
44
+ *
32
45
  * Usage:
33
46
  * node .agents/scripts/deliver-light.js --prompt "<text>" \
34
47
  * --creates path,path --acceptance 1 --route lite --reason "<why>"
@@ -36,7 +49,8 @@
36
49
  * node .agents/scripts/deliver-light.js --backstop --story 4741
37
50
  *
38
51
  * Exit codes: 0 ok (proceed / clean backstop), 1 usage error, 2 the gate did
39
- * not proceed light (ask-operator / escalate-plan), 3 the diff backstop blocked.
52
+ * not proceed light (ask-operator, or an `escalated` terminal), 3 the diff
53
+ * backstop blocked.
40
54
  */
41
55
 
42
56
  import { parseArgs } from 'node:util';
@@ -55,6 +69,11 @@ import {
55
69
  assemblePlanStories,
56
70
  createStoryIssues,
57
71
  } from './lib/orchestration/plan-persist/story-ops.js';
72
+ import {
73
+ buildEscalationTerminal,
74
+ emitTerminalEnvelope,
75
+ exitCodeForTerminal,
76
+ } from './lib/orchestration/story-deliver-terminal.js';
58
77
  import { createProvider } from './lib/provider-factory.js';
59
78
 
60
79
  const HELP = `\
@@ -75,7 +94,8 @@ Gate options:
75
94
  --route <r> Ledgered model verdict route: lite | full.
76
95
  --reason <text> Recorded reason for a lite verdict (required for lite).
77
96
  --amends <#id> Mark this as an amendment of an existing issue.
78
- --yes Unattended: over-scope fails closed to /plan (no prompt).
97
+ --yes Unattended: over-scope emits an escalated terminal
98
+ envelope and ENDS the session (no prompt, no fallback).
79
99
 
80
100
  Backstop options:
81
101
  --backstop Re-check the ACTUAL diff after implementation.
@@ -291,10 +311,39 @@ async function runBackstopMode(values) {
291
311
  /**
292
312
  * Gate mode — judge the prompt and, on proceed, author the receipt Story.
293
313
  *
314
+ * The three outcomes are deliberately asymmetric in what they emit:
315
+ *
316
+ * - **`escalate-plan`** returns a schema-validated `escalated` **terminal
317
+ * envelope** and stops (Story #4746). It is placed **first**, above every
318
+ * creation call site, so "nothing was started" is a property of the
319
+ * control flow rather than a claim the envelope makes about itself.
320
+ * - **`ask-operator`** is unchanged: the plain gate envelope and exit 2. It
321
+ * is not terminal — the operator has a choice to make, and manufacturing a
322
+ * terminal for it would end a session that is supposed to be waiting.
323
+ * - **`proceed-light`** authors the receipt Story and prints the hand-off.
324
+ *
325
+ * The injectable seams exist so the no-side-effect guarantee is testable
326
+ * without a network: a test asserts the escalate path never reaches them.
327
+ *
294
328
  * @param {object} values Parsed CLI values.
329
+ * @param {{
330
+ * createProviderFn?: typeof createProvider,
331
+ * resolveConfigFn?: typeof resolveConfig,
332
+ * createReceiptFn?: typeof createLightReceipt,
333
+ * emitFn?: typeof emit,
334
+ * emitTerminalFn?: typeof emitTerminalEnvelope,
335
+ * }} [deps]
295
336
  * @returns {Promise<number>}
296
337
  */
297
- async function runGateMode(values) {
338
+ export async function runGateMode(values, deps = {}) {
339
+ const {
340
+ createProviderFn = createProvider,
341
+ resolveConfigFn = resolveConfig,
342
+ createReceiptFn = createLightReceipt,
343
+ emitFn = emit,
344
+ emitTerminalFn = emitTerminalEnvelope,
345
+ } = deps;
346
+
298
347
  if (!values.prompt || String(values.prompt).trim() === '') {
299
348
  process.stderr.write(HELP);
300
349
  throw new Error('[deliver-light] --prompt <text> is required for the gate');
@@ -311,8 +360,20 @@ async function runGateMode(values) {
311
360
  yes: values.yes === true,
312
361
  });
313
362
 
363
+ if (gate.action === 'escalate-plan') {
364
+ const envelope = buildEscalationTerminal({
365
+ prompt: String(values.prompt),
366
+ reasons: gate.outcome.reasons,
367
+ });
368
+ emitTerminalFn(envelope);
369
+ Logger.warn(
370
+ `[deliver-light] ESCALATED to /plan — this session ENDS here; run ${envelope.nextCommand} in a FRESH session: ${gate.outcome.reasons.join('; ')}`,
371
+ );
372
+ return exitCodeForTerminal(envelope);
373
+ }
374
+
314
375
  if (gate.action !== 'proceed-light') {
315
- emit(
376
+ emitFn(
316
377
  { mode: 'gate', action: gate.action, outcome: gate.outcome },
317
378
  values.pretty,
318
379
  );
@@ -322,8 +383,8 @@ async function runGateMode(values) {
322
383
  return EXIT_NOT_PROCEED;
323
384
  }
324
385
 
325
- const provider = createProvider(resolveConfig());
326
- const receipt = await createLightReceipt({
386
+ const provider = createProviderFn(resolveConfigFn());
387
+ const receipt = await createReceiptFn({
327
388
  provider,
328
389
  prompt: String(values.prompt),
329
390
  changedFiles: [
@@ -332,7 +393,7 @@ async function runGateMode(values) {
332
393
  ],
333
394
  amends: values.amends ?? null,
334
395
  });
335
- emit(
396
+ emitFn(
336
397
  {
337
398
  mode: 'gate',
338
399
  action: 'proceed-light',
@@ -382,4 +443,5 @@ async function main() {
382
443
  runAsCli(import.meta.url, main, {
383
444
  source: 'deliver-light',
384
445
  propagateExitCode: true,
446
+ usage: HELP,
385
447
  });
@@ -136,4 +136,7 @@ export async function runDeliverRecover({
136
136
  return { success: true, result: recovery };
137
137
  }
138
138
 
139
- runAsCli(import.meta.url, runDeliverRecover, { source: 'deliver-recover' });
139
+ runAsCli(import.meta.url, runDeliverRecover, {
140
+ source: 'deliver-recover',
141
+ usage: HELP,
142
+ });
@@ -248,4 +248,20 @@ export async function main(args = process.argv.slice(2)) {
248
248
 
249
249
  import { runAsCli } from './lib/cli-utils.js';
250
250
 
251
- runAsCli(import.meta.url, main, { source: 'DiagnoseFriction' });
251
+ runAsCli(import.meta.url, main, {
252
+ source: 'DiagnoseFriction',
253
+ usage: {
254
+ invocation:
255
+ 'node .agents/scripts/diagnose-friction.js [--story <id>] [--epic <id>] --cmd <command with args...>',
256
+ summary:
257
+ 'Run a command through the diagnostic interceptor: stream its output, then append a local friction signal describing the failure. Never posts to the ticket.',
258
+ flags: [
259
+ ['--story <id>', 'Story the friction belongs to.'],
260
+ ['--epic <id>', 'Epic the friction belongs to.'],
261
+ [
262
+ '--cmd <command...>',
263
+ 'The command to execute; everything after it is the argv (required).',
264
+ ],
265
+ ],
266
+ },
267
+ });
@@ -47,6 +47,21 @@ import { runAsCli } from './lib/cli-utils.js';
47
47
 
48
48
  const DEFAULT_SCOPE = 'diagnose';
49
49
 
50
+ /**
51
+ * The `--help` block. Single-homed here so the CLI gate in `runAsCli` and the
52
+ * `validateDiagnoseArgs` HELP-sentinel branch print the same bytes.
53
+ */
54
+ const HELP = [
55
+ 'Usage: diagnose [--scope <scope>] [--fail-on-blocker] [--json]',
56
+ '',
57
+ 'Options:',
58
+ ' --scope <s> Filter checks by scope (default: diagnose).',
59
+ ' Use `all` to run every registered check.',
60
+ ' --fail-on-blocker Exit 2 when at least one finding is a blocker.',
61
+ ' --json Emit findings as a single line of JSON.',
62
+ '',
63
+ ].join('\n');
64
+
50
65
  /**
51
66
  * Parse the CLI argv slice (i.e. argv without the leading node/script
52
67
  * elements). Exported for direct unit testing — keeps the parse pure so
@@ -203,19 +218,7 @@ export async function runDiagnose({
203
218
  */
204
219
  export function validateDiagnoseArgs(err) {
205
220
  if (err && err.message === 'HELP') {
206
- return {
207
- kind: 'help',
208
- text: [
209
- 'Usage: diagnose [--scope <scope>] [--fail-on-blocker] [--json]',
210
- '',
211
- 'Options:',
212
- ' --scope <s> Filter checks by scope (default: diagnose).',
213
- ' Use `all` to run every registered check.',
214
- ' --fail-on-blocker Exit 2 when at least one finding is a blocker.',
215
- ' --json Emit findings as a single line of JSON.',
216
- '',
217
- ].join('\n'),
218
- };
221
+ return { kind: 'help', text: HELP };
219
222
  }
220
223
  const message = err?.message ? err.message : String(err);
221
224
  return { kind: 'error', text: `[diagnose] ${message}\n`, exitCode: 1 };
@@ -237,4 +240,7 @@ async function main() {
237
240
  }
238
241
  }
239
242
 
240
- runAsCli(import.meta.url, main, { source: 'diagnose' });
243
+ runAsCli(import.meta.url, main, {
244
+ source: 'diagnose',
245
+ usage: HELP,
246
+ });
@@ -144,4 +144,23 @@ async function main() {
144
144
  );
145
145
  }
146
146
 
147
- runAsCli(import.meta.url, main, { source: 'drain-pending-cleanup' });
147
+ runAsCli(import.meta.url, main, {
148
+ source: 'drain-pending-cleanup',
149
+ usage: {
150
+ invocation:
151
+ 'node .agents/scripts/drain-pending-cleanup.js [--dry-run] [--no-escalate] [--worktree-root <path>]',
152
+ summary:
153
+ 'Drain the pending-worktree-cleanup manifest, removing trees whose holders have exited.',
154
+ flags: [
155
+ ['--dry-run', 'Report each entry and its holders; remove nothing.'],
156
+ [
157
+ '--no-escalate',
158
+ 'Do not escalate to a forced removal for stuck entries.',
159
+ ],
160
+ [
161
+ '--worktree-root <path>',
162
+ 'Worktree root (default: delivery.worktreeIsolation.root).',
163
+ ],
164
+ ],
165
+ },
166
+ });
@@ -238,4 +238,23 @@ async function main() {
238
238
  await runEvidenceGate({ ...args, runnerArgs });
239
239
  }
240
240
 
241
- runAsCli(import.meta.url, main, { source: 'evidence-gate' });
241
+ runAsCli(import.meta.url, main, {
242
+ source: 'evidence-gate',
243
+ usage: {
244
+ invocation:
245
+ 'node .agents/scripts/evidence-gate.js --scope-id <id> --gate <name> [--standalone] [--no-evidence] [--cwd <path>] [--worktree <path>]',
246
+ summary:
247
+ 'Run one named gate, reusing a prior evidence stamp for the same HEAD instead of re-running it.',
248
+ flags: [
249
+ ['--scope-id <id>', 'Story id the evidence is scoped to (required).'],
250
+ ['--gate <name>', 'Gate to run (e.g. lint, typecheck) (required).'],
251
+ ['--standalone', 'Run outside a close pipeline and stamp the evidence.'],
252
+ [
253
+ '--no-evidence',
254
+ 'Ignore and do not write evidence — always run the gate.',
255
+ ],
256
+ ['--cwd <path>', 'Repository root (default: project root).'],
257
+ ['--worktree <path>', 'Worktree the gate runs in.'],
258
+ ],
259
+ },
260
+ });
@@ -608,4 +608,17 @@ export {
608
608
  spliceRegion,
609
609
  };
610
610
 
611
- runAsCli(import.meta.url, main, { source: 'generate-config-docs' });
611
+ runAsCli(import.meta.url, main, {
612
+ source: 'generate-config-docs',
613
+ usage: {
614
+ invocation: 'node .agents/scripts/generate-config-docs.js [--check]',
615
+ summary:
616
+ 'Regenerate the configuration reference from the .agentrc schema. Writes only when the generated content differs.',
617
+ flags: [
618
+ [
619
+ '--check',
620
+ 'Verify the doc is current and fail if stale; write nothing.',
621
+ ],
622
+ ],
623
+ },
624
+ });
@@ -221,4 +221,17 @@ export {
221
221
  spliceRegion,
222
222
  };
223
223
 
224
- runAsCli(import.meta.url, main, { source: 'generate-lifecycle-docs' });
224
+ runAsCli(import.meta.url, main, {
225
+ source: 'generate-lifecycle-docs',
226
+ usage: {
227
+ invocation: 'node .agents/scripts/generate-lifecycle-docs.js [--check]',
228
+ summary:
229
+ 'Regenerate the lifecycle-event table in docs/LIFECYCLE.md from the event schemas. Writes only when the generated content differs.',
230
+ flags: [
231
+ [
232
+ '--check',
233
+ 'Verify the doc is current and fail if stale; write nothing.',
234
+ ],
235
+ ],
236
+ },
237
+ });
@@ -198,4 +198,17 @@ async function main(argv = process.argv.slice(2)) {
198
198
 
199
199
  export { DOC_PATH, WORKFLOWS_DIR };
200
200
 
201
- runAsCli(import.meta.url, main, { source: 'generate-workflows-doc' });
201
+ runAsCli(import.meta.url, main, {
202
+ source: 'generate-workflows-doc',
203
+ usage: {
204
+ invocation: 'node .agents/scripts/generate-workflows-doc.js [--check]',
205
+ summary:
206
+ 'Regenerate the workflow catalog from .agents/workflows/. Writes only when the generated content differs.',
207
+ flags: [
208
+ [
209
+ '--check',
210
+ 'Verify the doc is current and fail if stale; write nothing.',
211
+ ],
212
+ ],
213
+ },
214
+ });