mandrel 2.14.0 → 2.16.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 (75) hide show
  1. package/.agents/audit-checklists/navigability.md +1 -1
  2. package/.agents/docs/workflows.md +4 -4
  3. package/.agents/scripts/acceptance-eval.js +18 -1
  4. package/.agents/scripts/agents-bootstrap-github.js +22 -1
  5. package/.agents/scripts/apply-quality-bootstrap.js +6 -0
  6. package/.agents/scripts/audit-labels-bootstrap.js +15 -1
  7. package/.agents/scripts/audit-to-stories.js +26 -1
  8. package/.agents/scripts/boot-sweep.js +4 -1
  9. package/.agents/scripts/bootstrap.js +1 -0
  10. package/.agents/scripts/check-arch-cycles.js +20 -0
  11. package/.agents/scripts/check-baselines.js +8 -2
  12. package/.agents/scripts/check-context-budget.js +40 -5
  13. package/.agents/scripts/check-dead-exports.js +21 -0
  14. package/.agents/scripts/check-doc-links.js +12 -1
  15. package/.agents/scripts/check-lifecycle-doc-drift.js +9 -0
  16. package/.agents/scripts/check-workflow-citations.js +332 -0
  17. package/.agents/scripts/deliver-light.js +32 -3
  18. package/.agents/scripts/deliver-recover.js +4 -1
  19. package/.agents/scripts/diagnose-friction.js +17 -1
  20. package/.agents/scripts/diagnose.js +20 -14
  21. package/.agents/scripts/drain-pending-cleanup.js +20 -1
  22. package/.agents/scripts/evidence-gate.js +20 -1
  23. package/.agents/scripts/generate-config-docs.js +14 -1
  24. package/.agents/scripts/generate-lifecycle-docs.js +14 -1
  25. package/.agents/scripts/generate-workflows-doc.js +14 -1
  26. package/.agents/scripts/git-cleanup.js +32 -1
  27. package/.agents/scripts/lib/cli-usage.js +174 -0
  28. package/.agents/scripts/lib/cli-utils.js +12 -0
  29. package/.agents/scripts/lib/close-validation/process.js +61 -15
  30. package/.agents/scripts/lib/doc-tiers.js +53 -10
  31. package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
  32. package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
  33. package/.agents/scripts/lib/orchestration/plan-context.js +205 -20
  34. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
  35. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +24 -15
  36. package/.agents/scripts/lib/workflow-closure.js +431 -0
  37. package/.agents/scripts/mandrel-update-preflight.js +9 -0
  38. package/.agents/scripts/nav-registry-diff.js +13 -0
  39. package/.agents/scripts/plan-context.js +21 -2
  40. package/.agents/scripts/plan-critics.js +10 -0
  41. package/.agents/scripts/plan-persist.js +33 -1
  42. package/.agents/scripts/plan-run-epilogue.js +12 -1
  43. package/.agents/scripts/quality-preview.js +17 -1
  44. package/.agents/scripts/resolve-doc-tiers.js +13 -0
  45. package/.agents/scripts/resolve-stories.js +1 -0
  46. package/.agents/scripts/resync-status-column.js +4 -1
  47. package/.agents/scripts/signals-view.js +11 -0
  48. package/.agents/scripts/single-story-close.js +24 -0
  49. package/.agents/scripts/single-story-confirm-merge.js +16 -0
  50. package/.agents/scripts/single-story-init.js +21 -1
  51. package/.agents/scripts/stories-wave-tick.js +1 -0
  52. package/.agents/scripts/sync-agentrc.js +16 -4
  53. package/.agents/scripts/update-ticket-state.js +23 -2
  54. package/.agents/workflows/audit-navigability.md +2 -2
  55. package/.agents/workflows/audit-to-stories.md +1 -1
  56. package/.agents/workflows/deliver.md +80 -81
  57. package/.agents/workflows/git-cleanup.md +9 -14
  58. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -13
  59. package/.agents/workflows/helpers/audit-lens-core.md +2 -2
  60. package/.agents/workflows/helpers/code-review.md +11 -11
  61. package/.agents/workflows/helpers/deliver-digest.md +7 -8
  62. package/.agents/workflows/helpers/deliver-light.md +222 -0
  63. package/.agents/workflows/helpers/deliver-reference.md +46 -14
  64. package/.agents/workflows/helpers/deliver-story-reference.md +55 -63
  65. package/.agents/workflows/helpers/deliver-story.md +22 -22
  66. package/.agents/workflows/helpers/mandrel-sync-config.md +2 -2
  67. package/.agents/workflows/helpers/parallel-tooling.md +1 -2
  68. package/.agents/workflows/helpers/plan-reference.md +96 -17
  69. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -4
  70. package/.agents/workflows/mandrel-update.md +6 -6
  71. package/.agents/workflows/plan.md +90 -85
  72. package/.agents/workflows/prototype.md +104 -0
  73. package/docs/CHANGELOG.md +32 -0
  74. package/package.json +2 -1
  75. package/.agents/workflows/deliver-light.md +0 -148
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Workflow read-tier closure resolver (Story #4752).
3
+ *
4
+ * `.agents/workflows/**` is the largest body of instruction Mandrel ships, and
5
+ * until this module it sat in none of the five doc read-tiers `doc-tiers.js`
6
+ * resolves — measured only by a per-file spine ceiling that is satisfied by
7
+ * moving prose into a linked helper. Workflows are a **graph**, not a flat set,
8
+ * so this module resolves each entry point's transitive markdown-link closure
9
+ * into two numbers:
10
+ *
11
+ * - **mandatory closure** — the entry point plus the transitive closure of
12
+ * its `mandatoryReads:` frontmatter edges. This is what a session is
13
+ * *forced* to read, and it is what `check-context-budget.js` ratchets.
14
+ * - **reachable closure** — the entry point plus every workflow markdown file
15
+ * transitively linked from it. Recorded as a drift signal, never gated.
16
+ *
17
+ * **The marker is source-side and per-edge.** A workflow declares the reads it
18
+ * requires in its own frontmatter (`mandatoryReads: [path, …]`, flow or block
19
+ * style, resolved relative to the declaring file). Tier is not intrinsic to a
20
+ * helper — the same file is mandatory from one workflow and on-demand from
21
+ * another — so it cannot live in the target. The key is optional: an absent
22
+ * key means zero mandatory edges and is never an error. Every reachable link
23
+ * not named in `mandatoryReads` is classified on-demand.
24
+ *
25
+ * **Entry points** are the workflows a session can be invoked on: every
26
+ * top-level `.agents/workflows/*.md`, plus any `helpers/*.md` whose H1 declares
27
+ * a slash command named after the file itself (`helpers/deliver-story.md` →
28
+ * `# /deliver-story …`) — a command-shaped helper is invoked directly, so it
29
+ * owns a closure of its own. Plain helpers and appendices are reachable, never
30
+ * entry points; counting every file as an entry point would collapse the
31
+ * mandatory/on-demand distinction into "all workflow bytes".
32
+ *
33
+ * **Failure modes are loud** — a ratchet that silently shrinks its own closure
34
+ * is worse than none:
35
+ * - a `mandatoryReads` entry that does not resolve to a workflow markdown
36
+ * file throws, naming the declaring workflow and the offending path;
37
+ * - a cycle among `mandatoryReads` edges throws, naming the cycle.
38
+ * The **reachable** walk is deliberately cycle-*tolerant* rather than fatal:
39
+ * bidirectional prose cross-references are normal and correct authoring (a
40
+ * spine points at its digest, the digest points back at the spine), so that
41
+ * walk terminates via a visited set and counts each file exactly once — it
42
+ * neither loops nor truncates. Only the gated mandatory graph, where a loop is
43
+ * a genuine authoring error, fails closed.
44
+ *
45
+ * The walk is confined to `.agents/workflows/**`: links out to
46
+ * `.agents/rules/**` or `.agents/skills/**` are neither followed nor recorded,
47
+ * because those are already tiered as flat sets by `doc-tiers.js` and
48
+ * following them would double-count them.
49
+ *
50
+ * Security (security-baseline § Data Leakage & Logging): every value returned
51
+ * or thrown is a repo-relative path or a byte count — never file contents.
52
+ */
53
+
54
+ import nodeFs from 'node:fs';
55
+ import path from 'node:path';
56
+
57
+ /**
58
+ * Repo-relative root of the workflow tree. The closure never escapes it.
59
+ * @type {string}
60
+ */
61
+ const WORKFLOWS_ROOT = '.agents/workflows';
62
+
63
+ /** Path-segment count of a top-level workflow (`.agents/workflows/x.md`). */
64
+ const TOP_LEVEL_DEPTH = 3;
65
+
66
+ // All RegExp instances are built via the constructor (rather than literal
67
+ // `/.../`) so the maintainability engine's AST walker (typhonjs-escomplex) can
68
+ // score this file — it crashes on `RegExpLiteral` nodes. Same workaround as
69
+ // lib/audit-suite/frontmatter.js.
70
+ // biome-ignore-start lint/complexity/useRegexLiterals: typhonjs-escomplex MI workaround
71
+ const FRONTMATTER_RE = new RegExp(String.raw`^---\r?\n([\s\S]*?)\r?\n---`);
72
+ const NEWLINE_RE = new RegExp(String.raw`\r?\n`);
73
+ const MANDATORY_KEY_RE = new RegExp(String.raw`^mandatoryReads\s*:(.*)$`);
74
+ const BLOCK_ITEM_RE = new RegExp(String.raw`^\s*-\s*(.+)$`);
75
+ const MD_LINK_RE = new RegExp(String.raw`\]\(\s*([^)\s]+)`, 'g');
76
+ const COMMAND_H1_RE = new RegExp(String.raw`^#\s+/([A-Za-z0-9._-]+)`, 'm');
77
+ // biome-ignore-end lint/complexity/useRegexLiterals: typhonjs-escomplex MI workaround
78
+
79
+ /**
80
+ * Default fs surface — the same injectable subset `doc-tiers.js` uses.
81
+ * @typedef {{
82
+ * readdirSync: (p: string, o?: object) => any[],
83
+ * readFileSync: (p: string, enc: string) => string,
84
+ * statSync: (p: string) => { size: number },
85
+ * }} FsLike
86
+ */
87
+
88
+ /**
89
+ * A loaded workflow document.
90
+ * @typedef {{ rel: string, bytes: number, source: string }} WorkflowDoc
91
+ */
92
+
93
+ /**
94
+ * Strip surrounding quotes and whitespace from a scalar YAML value.
95
+ *
96
+ * @param {string} value
97
+ * @returns {string}
98
+ */
99
+ function unquote(value) {
100
+ const t = String(value ?? '').trim();
101
+ const quoted =
102
+ t.length >= 2 &&
103
+ ((t.startsWith('"') && t.endsWith('"')) ||
104
+ (t.startsWith("'") && t.endsWith("'")));
105
+ return quoted ? t.slice(1, -1).trim() : t;
106
+ }
107
+
108
+ /**
109
+ * Convert a path to posix separators.
110
+ *
111
+ * @param {string} p
112
+ * @returns {string}
113
+ */
114
+ function toPosix(p) {
115
+ return p.split(path.sep).join('/');
116
+ }
117
+
118
+ /**
119
+ * Return the raw frontmatter block of a markdown source (`''` when absent).
120
+ *
121
+ * @param {string} source
122
+ * @returns {string}
123
+ */
124
+ function frontmatterBlock(source) {
125
+ const m = FRONTMATTER_RE.exec(String(source ?? ''));
126
+ return m ? m[1] : '';
127
+ }
128
+
129
+ /**
130
+ * Collect a YAML block-sequence (`- item`) starting at `start`. Blank lines are
131
+ * skipped; the first non-item, non-blank line ends the sequence.
132
+ *
133
+ * @param {string[]} lines
134
+ * @param {number} start
135
+ * @returns {string[]}
136
+ */
137
+ function blockSequence(lines, start) {
138
+ const out = [];
139
+ for (let i = start; i < lines.length; i += 1) {
140
+ const item = BLOCK_ITEM_RE.exec(lines[i]);
141
+ if (item) {
142
+ const value = unquote(item[1]);
143
+ if (value) out.push(value);
144
+ } else if (lines[i].trim() !== '') {
145
+ break;
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * Split a YAML flow sequence (`[a.md, b.md]`) into its scalar items.
153
+ *
154
+ * @param {string} inline
155
+ * @returns {string[]}
156
+ */
157
+ function flowSequence(inline) {
158
+ const body = inline.replace('[', '').replace(']', '');
159
+ return body
160
+ .split(',')
161
+ .map(unquote)
162
+ .filter((v) => v.length > 0);
163
+ }
164
+
165
+ /**
166
+ * Parse the optional `mandatoryReads:` frontmatter list from a workflow source.
167
+ * Supports the flow (`[a.md, b.md]`), block (`- a.md` lines), and single-scalar
168
+ * forms. An absent key resolves to `[]` — zero mandatory edges, never an error.
169
+ *
170
+ * @param {string} source
171
+ * @returns {string[]} raw specifiers, relative to the declaring file
172
+ */
173
+ function parseMandatoryReads(source) {
174
+ const lines = frontmatterBlock(source).split(NEWLINE_RE);
175
+ const idx = lines.findIndex((line) => MANDATORY_KEY_RE.test(line));
176
+ if (idx < 0) return [];
177
+ const inline = unquote(MANDATORY_KEY_RE.exec(lines[idx])[1]);
178
+ if (inline.startsWith('[')) return flowSequence(inline);
179
+ if (inline.length > 0) return [inline];
180
+ return blockSequence(lines, idx + 1);
181
+ }
182
+
183
+ /**
184
+ * Harvest every markdown link target in a source, anchors stripped.
185
+ *
186
+ * @param {string} source
187
+ * @returns {string[]}
188
+ */
189
+ function parseLinkTargets(source) {
190
+ const out = [];
191
+ for (const m of String(source ?? '').matchAll(MD_LINK_RE)) {
192
+ const target = m[1].split('#')[0].trim();
193
+ if (target.length > 0) out.push(target);
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Resolve a link/`mandatoryReads` specifier declared in `fromRel` to a known
200
+ * workflow doc, or `null` when it is external, non-markdown, or outside the
201
+ * workflow tree.
202
+ *
203
+ * @param {string} fromRel repo-relative posix path of the declaring file
204
+ * @param {string} spec
205
+ * @param {Map<string, WorkflowDoc>} docs
206
+ * @returns {string | null}
207
+ */
208
+ function resolveSpec(fromRel, spec, docs) {
209
+ if (!spec.endsWith('.md')) return null;
210
+ if (spec.includes('://') || spec.startsWith('mailto:')) return null;
211
+ const rel = path.posix.join(path.posix.dirname(fromRel), spec);
212
+ return docs.has(rel) ? rel : null;
213
+ }
214
+
215
+ /**
216
+ * Resolve a workflow's declared mandatory edges. Throws when an entry does not
217
+ * resolve to a workflow markdown file — a mandatory read pointing at nothing is
218
+ * a silent hole in the ratchet, so it fails loudly, naming both the declaring
219
+ * workflow and the offending path.
220
+ *
221
+ * @param {WorkflowDoc} doc
222
+ * @param {Map<string, WorkflowDoc>} docs
223
+ * @returns {string[]}
224
+ */
225
+ function mandatoryEdges(doc, docs) {
226
+ const out = [];
227
+ for (const spec of parseMandatoryReads(doc.source)) {
228
+ const rel = resolveSpec(doc.rel, spec, docs);
229
+ if (!rel) {
230
+ throw new Error(
231
+ `[workflow-closure] ${doc.rel} declares mandatoryReads "${spec}", which does not resolve to a markdown file under ${WORKFLOWS_ROOT}`,
232
+ );
233
+ }
234
+ out.push(rel);
235
+ }
236
+ return out;
237
+ }
238
+
239
+ /**
240
+ * Depth-first walk of the mandatory-edge graph. Cycle-fatal: a `mandatoryReads`
241
+ * loop is an authoring error, so it throws naming the cycle rather than looping
242
+ * or silently truncating the closure.
243
+ *
244
+ * @param {string} rel
245
+ * @param {Map<string, WorkflowDoc>} docs
246
+ * @param {string[]} stack in-progress DFS path
247
+ * @param {Set<string>} seen finished nodes
248
+ * @returns {Set<string>} `seen`
249
+ */
250
+ function walkMandatory(rel, docs, stack, seen) {
251
+ if (stack.includes(rel)) {
252
+ throw new Error(
253
+ `[workflow-closure] mandatoryReads cycle: ${[...stack, rel].join(' -> ')}`,
254
+ );
255
+ }
256
+ if (seen.has(rel)) return seen;
257
+ seen.add(rel);
258
+ stack.push(rel);
259
+ for (const next of mandatoryEdges(docs.get(rel), docs)) {
260
+ walkMandatory(next, docs, stack, seen);
261
+ }
262
+ stack.pop();
263
+ return seen;
264
+ }
265
+
266
+ /**
267
+ * Breadth-first walk of the markdown-link graph. Cycle-tolerant by design:
268
+ * bidirectional cross-references between a spine and its helper are correct
269
+ * authoring, so the visited set makes the walk terminate with each file counted
270
+ * exactly once.
271
+ *
272
+ * @param {string} rel
273
+ * @param {Map<string, WorkflowDoc>} docs
274
+ * @returns {Set<string>}
275
+ */
276
+ function walkReachable(rel, docs) {
277
+ const seen = new Set();
278
+ const queue = [rel];
279
+ while (queue.length > 0) {
280
+ const current = queue.shift();
281
+ if (seen.has(current)) continue;
282
+ seen.add(current);
283
+ for (const spec of parseLinkTargets(docs.get(current).source)) {
284
+ const next = resolveSpec(current, spec, docs);
285
+ if (next && !seen.has(next)) queue.push(next);
286
+ }
287
+ }
288
+ return seen;
289
+ }
290
+
291
+ /**
292
+ * Recursively collect repo-relative posix paths of every `.md` file under an
293
+ * absolute directory. An unreadable directory yields nothing (silent skip).
294
+ *
295
+ * @param {FsLike} fs
296
+ * @param {string} dirAbs
297
+ * @param {string} root
298
+ * @param {string[]} out
299
+ * @returns {string[]} `out`
300
+ */
301
+ function listMarkdown(fs, dirAbs, root, out) {
302
+ let dirents;
303
+ try {
304
+ dirents = fs.readdirSync(dirAbs, { withFileTypes: true });
305
+ } catch {
306
+ return out;
307
+ }
308
+ for (const dirent of dirents) {
309
+ const abs = path.join(dirAbs, dirent.name);
310
+ if (dirent.isDirectory()) listMarkdown(fs, abs, root, out);
311
+ else if (dirent.name.endsWith('.md'))
312
+ out.push(toPosix(path.relative(root, abs)));
313
+ }
314
+ return out;
315
+ }
316
+
317
+ /**
318
+ * Load every workflow markdown file into a `rel -> { rel, bytes, source }` map.
319
+ *
320
+ * @param {string} root absolute repo root
321
+ * @param {FsLike} fs
322
+ * @returns {Map<string, WorkflowDoc>}
323
+ */
324
+ function loadDocs(root, fs) {
325
+ const dirAbs = path.resolve(root, WORKFLOWS_ROOT);
326
+ const docs = new Map();
327
+ for (const rel of listMarkdown(fs, dirAbs, root, []).sort()) {
328
+ const abs = path.resolve(root, rel);
329
+ try {
330
+ docs.set(rel, {
331
+ rel,
332
+ bytes: fs.statSync(abs).size,
333
+ source: fs.readFileSync(abs, 'utf8'),
334
+ });
335
+ } catch {
336
+ // Unreadable file — skipped silently, like the sibling tier resolvers.
337
+ }
338
+ }
339
+ return docs;
340
+ }
341
+
342
+ /**
343
+ * True when a workflow is invocable in its own right: a top-level workflow, or
344
+ * a helper whose H1 declares a slash command **named after the file itself**
345
+ * (`helpers/deliver-story.md` → `# /deliver-story …`). The self-naming test is
346
+ * what separates an invocable helper from an appendix that merely titles itself
347
+ * after the command it documents (`helpers/deliver-reference.md` →
348
+ * `# /deliver — reference appendix`), which is read on demand, never invoked.
349
+ *
350
+ * @param {WorkflowDoc} doc
351
+ * @returns {boolean}
352
+ */
353
+ function isEntryPoint(doc) {
354
+ if (doc.rel.split('/').length === TOP_LEVEL_DEPTH) return true;
355
+ const h1 = COMMAND_H1_RE.exec(doc.source);
356
+ return h1 ? h1[1] === path.posix.basename(doc.rel, '.md') : false;
357
+ }
358
+
359
+ /**
360
+ * Materialize a path set as sorted `{ path, bytes }` entries.
361
+ *
362
+ * @param {Iterable<string>} rels
363
+ * @param {Map<string, WorkflowDoc>} docs
364
+ * @returns {Array<{ path: string, bytes: number }>}
365
+ */
366
+ function toEntries(rels, docs) {
367
+ return [...rels]
368
+ .map((rel) => ({ path: rel, bytes: docs.get(rel)?.bytes ?? 0 }))
369
+ .sort((a, b) => a.path.localeCompare(b.path));
370
+ }
371
+
372
+ /**
373
+ * Sum the on-disk bytes of a path set.
374
+ *
375
+ * @param {Iterable<string>} rels
376
+ * @param {Map<string, WorkflowDoc>} docs
377
+ * @returns {number}
378
+ */
379
+ function sumBytes(rels, docs) {
380
+ let total = 0;
381
+ for (const rel of rels) total += docs.get(rel)?.bytes ?? 0;
382
+ return total;
383
+ }
384
+
385
+ /**
386
+ * Resolve the workflow tier: every entry point's transitive markdown-link
387
+ * closure, partitioned into the gated mandatory set and the recorded on-demand
388
+ * remainder. Returns empty collections when `.agents/workflows` is absent — an
389
+ * entry point resolving empty is skipped silently.
390
+ *
391
+ * @param {string} root absolute repo root
392
+ * @param {{ fs?: FsLike }} [opts]
393
+ * @returns {{
394
+ * entryPoints: Array<{ path: string, mandatoryBytes: number, reachableBytes: number }>,
395
+ * mandatoryFiles: Array<{ path: string, bytes: number }>,
396
+ * onDemandFiles: Array<{ path: string, bytes: number }>,
397
+ * mandatoryTotalBytes: number,
398
+ * reachableTotalBytes: number,
399
+ * }}
400
+ * @throws {Error} on an unresolvable `mandatoryReads` entry or a mandatory cycle
401
+ */
402
+ export function resolveWorkflowClosures(root, { fs = nodeFs } = {}) {
403
+ const docs = loadDocs(root, fs);
404
+ const entryPoints = [];
405
+ const mandatoryUnion = new Set();
406
+ const reachableUnion = new Set();
407
+
408
+ for (const doc of docs.values()) {
409
+ if (!isEntryPoint(doc)) continue;
410
+ const mandatory = walkMandatory(doc.rel, docs, [], new Set());
411
+ const reachable = walkReachable(doc.rel, docs);
412
+ for (const rel of mandatory) mandatoryUnion.add(rel);
413
+ for (const rel of reachable) reachableUnion.add(rel);
414
+ entryPoints.push({
415
+ path: doc.rel,
416
+ mandatoryBytes: sumBytes(mandatory, docs),
417
+ reachableBytes: sumBytes(reachable, docs),
418
+ });
419
+ }
420
+
421
+ const onDemandUnion = [...reachableUnion].filter(
422
+ (rel) => !mandatoryUnion.has(rel),
423
+ );
424
+ return {
425
+ entryPoints: entryPoints.sort((a, b) => a.path.localeCompare(b.path)),
426
+ mandatoryFiles: toEntries(mandatoryUnion, docs),
427
+ onDemandFiles: toEntries(onDemandUnion, docs),
428
+ mandatoryTotalBytes: sumBytes(mandatoryUnion, docs),
429
+ reachableTotalBytes: sumBytes(reachableUnion, docs),
430
+ };
431
+ }
@@ -232,4 +232,13 @@ async function main() {
232
232
  runAsCli(import.meta.url, main, {
233
233
  source: 'mandrel-update-preflight',
234
234
  propagateExitCode: true,
235
+ usage: {
236
+ invocation: 'node .agents/scripts/mandrel-update-preflight.js',
237
+ summary:
238
+ 'First-run guard for /mandrel-update: hard-stops on a non-consumer repo, warns on a dirty git index and on being offline.',
239
+ flags: [],
240
+ notes: [
241
+ 'Exit codes:\n 0 safe to update (warnings may be present)\n 1 blocker — do not update',
242
+ ],
243
+ },
235
244
  });
@@ -446,4 +446,17 @@ export { main };
446
446
  runAsCli(import.meta.url, main, {
447
447
  source: 'nav-registry-diff',
448
448
  propagateExitCode: true,
449
+ usage: {
450
+ invocation:
451
+ 'node .agents/scripts/nav-registry-diff.js --routes <file> --nav <file> [--refs <file>] [--json] [--strict]',
452
+ summary:
453
+ 'Diff a route inventory against the nav registry: report routes with no nav door and nav hrefs pointing nowhere.',
454
+ flags: [
455
+ ['--routes <file>', 'JSON array of route records (required).'],
456
+ ['--nav <file>', 'JSON array of nav entries (required).'],
457
+ ['--refs <file>', 'JSON array of additional href references.'],
458
+ ['--json', 'Emit the diff as JSON instead of a text report.'],
459
+ ['--strict', 'Exit non-zero on any finding.'],
460
+ ],
461
+ },
449
462
  });
@@ -167,7 +167,9 @@ export async function emitPlanContext({
167
167
  // Advisory only (Story #4722): signals, no route — the planner owns
168
168
  // the trivial-vs-standard verdict and persist validates it by shape.
169
169
  // The nested `deliverLightSuggestion` is the recorded plan-side routing
170
- // handshake (Story #4741 AC-6) advisory, never an automatic reroute.
170
+ // handshake (Story #4741 AC-6) and `uiSurface` the recorded /prototype
171
+ // offer — advisory, never an automatic reroute. Both ride the digest
172
+ // because with `--out` the digest is the only thing the planner reads.
171
173
  complexitySignals: envelope.complexitySignals
172
174
  ? {
173
175
  artifactCount: envelope.complexitySignals.artifactCount,
@@ -176,6 +178,7 @@ export async function emitPlanContext({
176
178
  envelope.complexitySignals.sensitivePathClasses,
177
179
  deliverLightSuggestion:
178
180
  envelope.complexitySignals.deliverLightSuggestion ?? null,
181
+ uiSurface: envelope.complexitySignals.uiSurface ?? null,
179
182
  }
180
183
  : null,
181
184
  amends: envelope.amends ? { id: envelope.amends.id } : null,
@@ -339,4 +342,20 @@ async function main() {
339
342
  );
340
343
  }
341
344
 
342
- runAsCli(import.meta.url, main, { source: 'plan-context' });
345
+ runAsCli(import.meta.url, main, {
346
+ source: 'plan-context',
347
+ usage: {
348
+ invocation:
349
+ 'node .agents/scripts/plan-context.js (--seed "<text>" | --seed-file <path> | --tickets <ids> | --amends <id>) [--out <path>] [--pretty]',
350
+ summary:
351
+ 'Build the /plan authoring-context envelope on stdout. Exactly one entry form must be supplied.',
352
+ flags: [
353
+ ['--seed "<text>"', 'Inline seed prose.'],
354
+ ['--seed-file <path>', 'Seed document to read.'],
355
+ ['--tickets <ids>', 'Comma-separated existing ticket ids to re-plan.'],
356
+ ['--amends <id>', 'Amend the Spec of an existing Story.'],
357
+ ['--out <path>', 'Write the envelope to a file instead of stdout.'],
358
+ ['--pretty', 'Pretty-print the JSON envelope.'],
359
+ ],
360
+ },
361
+ });
@@ -312,4 +312,14 @@ async function main() {
312
312
  runAsCli(import.meta.url, main, {
313
313
  source: 'plan-critics',
314
314
  propagateExitCode: true,
315
+ usage: {
316
+ invocation:
317
+ 'node .agents/scripts/plan-critics.js --stories <file> [--tech-spec <file>]',
318
+ summary:
319
+ 'Score an authored plan draft with the maker-blind critics and print the verdict JSON on stdout.',
320
+ flags: [
321
+ ['--stories <file>', 'Authored stories.json (required).'],
322
+ ['--tech-spec <file>', 'Optional companion techspec.md.'],
323
+ ],
324
+ },
315
325
  });
@@ -438,4 +438,36 @@ async function main() {
438
438
  process.stdout.write(`${JSON.stringify(result)}\n`);
439
439
  }
440
440
 
441
- runAsCli(import.meta.url, main, { source: 'plan-persist' });
441
+ runAsCli(import.meta.url, main, {
442
+ source: 'plan-persist',
443
+ usage: {
444
+ invocation:
445
+ 'node .agents/scripts/plan-persist.js --stories <file> [--tech-spec <file>] [--dry-run] [options]',
446
+ summary:
447
+ 'Validate an authored plan and persist it as GitHub Stories. Prints the result envelope as JSON on stdout.',
448
+ flags: [
449
+ ['--stories <file>', 'Authored stories.json (required).'],
450
+ ['--tech-spec <file>', 'Optional companion techspec.md.'],
451
+ ['--plan-dir <dir>', 'Directory holding the plan artifacts.'],
452
+ [
453
+ '--plan-context <file>',
454
+ 'The plan-context envelope this draft was authored against.',
455
+ ],
456
+ ['--plan-acceptance <file>', 'Acceptance artifact to attach.'],
457
+ ['--source-tickets <ids>', 'Ticket ids this plan supersedes.'],
458
+ [
459
+ '--route-downgrade-reason <text>',
460
+ 'Why the authored route was downgraded.',
461
+ ],
462
+ ['--dry-run', 'Validate and report; create nothing.'],
463
+ ['--chain-on-clean', 'Persist immediately when the dry run is clean.'],
464
+ ['--no-close-superseded', 'Leave superseded source tickets open.'],
465
+ [
466
+ '--force-review',
467
+ 'Require the review gate even when it would be skipped.',
468
+ ],
469
+ ['--allow-over-budget', 'Permit a Spec over the context budget.'],
470
+ ['--allow-large-fan-out', 'Permit a Story count above the fan-out gate.'],
471
+ ],
472
+ },
473
+ });
@@ -139,4 +139,15 @@ function warnOnEmptyRollup(result) {
139
139
  );
140
140
  }
141
141
 
142
- await runAsCli(import.meta.url, main);
142
+ await runAsCli(import.meta.url, main, {
143
+ usage: {
144
+ invocation:
145
+ 'node .agents/scripts/plan-run-epilogue.js --stories <id,id,...> [--cwd <path>]',
146
+ summary:
147
+ 'Close out a delivery run: roll up the delivered Stories’ signals and report the run’s loop health.',
148
+ flags: [
149
+ ['--stories <ids>', 'Comma-separated delivered Story ids (required).'],
150
+ ['--cwd <path>', 'Repository root (default: process cwd).'],
151
+ ],
152
+ },
153
+ });
@@ -29,6 +29,22 @@ import {
29
29
  runCrapPreview,
30
30
  runMaintainabilityPreview,
31
31
  } from './lib/baselines/preview-gates.js';
32
+ import { respondToHelp } from './lib/cli-usage.js';
33
+
34
+ const USAGE = {
35
+ invocation:
36
+ 'node .agents/scripts/quality-preview.js [--staged | --changed-since <ref>] [--json]',
37
+ summary:
38
+ 'Preview the per-file maintainability and CRAP deltas for the change set, and exit non-zero on any threshold violation.',
39
+ flags: [
40
+ ['--staged', 'Score the git index only (the pre-commit-hook scope).'],
41
+ [
42
+ '--changed-since <ref>',
43
+ 'Score the diff against <ref> (default: HEAD). Last occurrence wins.',
44
+ ],
45
+ ['--json', 'Emit both gate envelopes plus the merged table as JSON.'],
46
+ ],
47
+ };
32
48
 
33
49
  /**
34
50
  * Parse `--changed-since <ref>` from argv. Defaults to `HEAD` when the flag is
@@ -327,7 +343,7 @@ const isDirect = (() => {
327
343
  }
328
344
  })();
329
345
 
330
- if (isDirect) {
346
+ if (isDirect && !respondToHelp(process.argv.slice(2), USAGE)) {
331
347
  runCli().then(({ exitCode }) => {
332
348
  process.exit(exitCode);
333
349
  });
@@ -80,4 +80,17 @@ runAsCli(import.meta.url, main, {
80
80
  source: 'resolve-doc-tiers',
81
81
  propagateExitCode: true,
82
82
  errorPrefix: '[resolve-doc-tiers] ❌ Fatal error',
83
+ usage: {
84
+ invocation:
85
+ 'node .agents/scripts/resolve-doc-tiers.js [--root <dir>] [--json]',
86
+ summary:
87
+ 'Print the resolved documentation tiers (always-loaded vs on-demand) as JSON.',
88
+ flags: [
89
+ [
90
+ '--root <dir>',
91
+ 'Repository root to resolve against (default: project root).',
92
+ ],
93
+ ['--json', 'Accepted for symmetry; output is always JSON.'],
94
+ ],
95
+ },
83
96
  });
@@ -235,4 +235,5 @@ async function main() {
235
235
  runAsCli(import.meta.url, main, {
236
236
  source: 'resolve-stories',
237
237
  propagateExitCode: true,
238
+ usage: HELP,
238
239
  });
@@ -178,4 +178,7 @@ export async function main(argv = process.argv.slice(2)) {
178
178
  process.stdout.write(`${JSON.stringify({ ticketId, ...result })}\n`);
179
179
  }
180
180
 
181
- runAsCli(import.meta.url, main, { source: 'resync-status-column' });
181
+ runAsCli(import.meta.url, main, {
182
+ source: 'resync-status-column',
183
+ usage: HELP,
184
+ });
@@ -290,6 +290,17 @@ runAsCli(
290
290
  },
291
291
  {
292
292
  source: 'signals-view',
293
+ usage: {
294
+ invocation:
295
+ 'node .agents/scripts/signals-view.js <run-id> [--story <id>] [--temp-root <path>]',
296
+ summary:
297
+ 'Render a run’s signal ledger as a tree, optionally narrowed to one Story.',
298
+ flags: [
299
+ ['<run-id>', 'Run id whose signals are read (required positional).'],
300
+ ['--story <id>', 'Show only signals emitted for this Story.'],
301
+ ['--temp-root <path>', 'Temp root holding the signal ledgers.'],
302
+ ],
303
+ },
293
304
  onError(err) {
294
305
  println(`signals-view: unexpected error: ${err?.message ?? err}`);
295
306
  process.exit(1);