mandrel 2.14.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 (68) hide show
  1. package/.agents/audit-checklists/navigability.md +1 -1
  2. package/.agents/docs/workflows.md +4 -5
  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 +1 -0
  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/doc-tiers.js +53 -10
  30. package/.agents/scripts/lib/orchestration/plan-context.js +16 -11
  31. package/.agents/scripts/lib/workflow-closure.js +431 -0
  32. package/.agents/scripts/mandrel-update-preflight.js +9 -0
  33. package/.agents/scripts/nav-registry-diff.js +13 -0
  34. package/.agents/scripts/plan-context.js +17 -1
  35. package/.agents/scripts/plan-critics.js +10 -0
  36. package/.agents/scripts/plan-persist.js +33 -1
  37. package/.agents/scripts/plan-run-epilogue.js +12 -1
  38. package/.agents/scripts/quality-preview.js +17 -1
  39. package/.agents/scripts/resolve-doc-tiers.js +13 -0
  40. package/.agents/scripts/resolve-stories.js +1 -0
  41. package/.agents/scripts/resync-status-column.js +4 -1
  42. package/.agents/scripts/signals-view.js +11 -0
  43. package/.agents/scripts/single-story-close.js +24 -0
  44. package/.agents/scripts/single-story-confirm-merge.js +16 -0
  45. package/.agents/scripts/single-story-init.js +21 -1
  46. package/.agents/scripts/stories-wave-tick.js +1 -0
  47. package/.agents/scripts/sync-agentrc.js +16 -4
  48. package/.agents/scripts/update-ticket-state.js +23 -2
  49. package/.agents/workflows/audit-navigability.md +2 -2
  50. package/.agents/workflows/audit-to-stories.md +1 -1
  51. package/.agents/workflows/deliver.md +80 -81
  52. package/.agents/workflows/git-cleanup.md +9 -14
  53. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -13
  54. package/.agents/workflows/helpers/audit-lens-core.md +2 -2
  55. package/.agents/workflows/helpers/code-review.md +11 -11
  56. package/.agents/workflows/helpers/deliver-digest.md +7 -8
  57. package/.agents/workflows/{deliver-light.md → helpers/deliver-light.md} +72 -24
  58. package/.agents/workflows/helpers/deliver-reference.md +46 -14
  59. package/.agents/workflows/helpers/deliver-story-reference.md +55 -63
  60. package/.agents/workflows/helpers/deliver-story.md +22 -22
  61. package/.agents/workflows/helpers/mandrel-sync-config.md +2 -2
  62. package/.agents/workflows/helpers/parallel-tooling.md +1 -2
  63. package/.agents/workflows/helpers/plan-reference.md +72 -14
  64. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -4
  65. package/.agents/workflows/mandrel-update.md +6 -6
  66. package/.agents/workflows/plan.md +86 -83
  67. package/docs/CHANGELOG.md +15 -0
  68. package/package.json +2 -1
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `.agents/scripts/lib/cli-usage.js` — the one implementation of `--help` for
3
+ * every top-level script under `.agents/scripts/`.
4
+ *
5
+ * ## Why
6
+ *
7
+ * A script's flag contract used to live in the workflow prose that invoked it
8
+ * (`deliver-story.md` Step 0 restating `single-story-init.js`'s `--dry-run` /
9
+ * `--steal`, and so on). Two homes for one contract is a drift class: the
10
+ * script changes, the prose does not, and the next agent runs a flag that no
11
+ * longer exists. Moving the enumeration into the script's own `--help` gives
12
+ * the contract a single home that ships with the code that implements it.
13
+ *
14
+ * ## Contract
15
+ *
16
+ * `--help` (or `-h`) is a **query**, never an error path:
17
+ *
18
+ * - writes non-empty text to **stdout** and exits **0**;
19
+ * - performs no GitHub write, acquires no lease, mutates no working tree.
20
+ *
21
+ * stdout — not `Logger.info` — because help output must survive
22
+ * `AGENT_LOG_LEVEL=silent` and carry no `[Orchestrator]` decoration; this is
23
+ * the same `process.stdout.write` carve-out that machine-parsable envelopes
24
+ * use (see `tests/enforcement/no-console.test.js`).
25
+ *
26
+ * The short-circuit itself lives in `runAsCli` (`lib/cli-utils.js`), which
27
+ * fires this module **before** the script's `main` runs — so the "no side
28
+ * effects" half of the contract holds structurally rather than by each
29
+ * script remembering to check first.
30
+ *
31
+ * ## Usage
32
+ *
33
+ * runAsCli(import.meta.url, main, {
34
+ * source: 'my-script',
35
+ * usage: {
36
+ * invocation: 'node .agents/scripts/my-script.js --story <id> [--json]',
37
+ * summary: 'One line on what the script does.',
38
+ * flags: [
39
+ * ['--story <id>', 'GitHub issue number of the Story (required).'],
40
+ * ['--json', 'Emit the envelope as JSON instead of prose.'],
41
+ * ],
42
+ * },
43
+ * });
44
+ *
45
+ * A pre-rendered string is accepted too, so a script that already shipped a
46
+ * hand-written `HELP` block adopts the shared gate without its observable
47
+ * text changing (`usage: HELP`).
48
+ */
49
+
50
+ /** Tokens that request help. */
51
+ export const HELP_FLAGS = Object.freeze(['--help', '-h']);
52
+
53
+ /** Left column width for the flag table; longer flags wrap to their own line. */
54
+ const FLAG_COLUMN = 22;
55
+
56
+ /**
57
+ * Did the caller ask for help? Scans up to the `--` end-of-flags separator so
58
+ * a positional literally named `--help` after `--` is not mistaken for a
59
+ * request.
60
+ *
61
+ * @param {string[]} argv Argument vector without the node/script entries.
62
+ * @returns {boolean}
63
+ */
64
+ export function wantsHelp(argv = []) {
65
+ if (!Array.isArray(argv)) return false;
66
+ for (const token of argv) {
67
+ if (token === '--') return false;
68
+ if (HELP_FLAGS.includes(token)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * Normalize one flag entry into `{ flag, description }`. Accepts a
75
+ * `[flag, description]` pair or an object, so call sites can use whichever
76
+ * reads better next to their option table.
77
+ *
78
+ * @param {[string, string]|{flag: string, description?: string}} entry
79
+ * @returns {{ flag: string, description: string }}
80
+ */
81
+ function normalizeFlag(entry) {
82
+ if (Array.isArray(entry)) {
83
+ return {
84
+ flag: String(entry[0] ?? ''),
85
+ description: String(entry[1] ?? ''),
86
+ };
87
+ }
88
+ return {
89
+ flag: String(entry?.flag ?? ''),
90
+ description: String(entry?.description ?? ''),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Render one ` --flag description` row, wrapping onto a second line when
96
+ * the flag itself is wider than the column.
97
+ *
98
+ * @param {{ flag: string, description: string }} row
99
+ * @returns {string}
100
+ */
101
+ function renderFlagRow({ flag, description }) {
102
+ if (!description) return ` ${flag}`;
103
+ if (flag.length >= FLAG_COLUMN) {
104
+ return ` ${flag}\n ${' '.repeat(FLAG_COLUMN)}${description}`;
105
+ }
106
+ return ` ${flag.padEnd(FLAG_COLUMN)}${description}`;
107
+ }
108
+
109
+ /**
110
+ * Append the `--help` row unless the spec already documents it, so every
111
+ * rendered block is self-describing without each call site repeating it.
112
+ *
113
+ * @param {Array<{ flag: string, description: string }>} rows
114
+ * @returns {Array<{ flag: string, description: string }>}
115
+ */
116
+ function withHelpRow(rows) {
117
+ const documented = rows.some((r) => r.flag.split(/[\s,]/)[0] === '--help');
118
+ if (documented) return rows;
119
+ return [...rows, { flag: '--help', description: 'Show this message.' }];
120
+ }
121
+
122
+ /**
123
+ * Render a usage spec into the text `--help` prints.
124
+ *
125
+ * @param {{
126
+ * invocation: string,
127
+ * summary?: string,
128
+ * flags?: Array<[string, string]|{flag: string, description?: string}>,
129
+ * notes?: string[],
130
+ * }} spec
131
+ * @returns {string} Text block, newline-terminated.
132
+ */
133
+ export function formatUsage(spec) {
134
+ const rows = withHelpRow((spec?.flags ?? []).map(normalizeFlag));
135
+ const blocks = [`Usage: ${spec?.invocation ?? ''}`.trim()];
136
+ if (spec?.summary) blocks.push(spec.summary);
137
+ blocks.push(['Flags:', ...rows.map(renderFlagRow)].join('\n'));
138
+ for (const note of spec?.notes ?? []) blocks.push(note);
139
+ return `${blocks.join('\n\n')}\n`;
140
+ }
141
+
142
+ /**
143
+ * Coerce either accepted `usage` shape — a spec object or a pre-rendered
144
+ * string — into the text to print.
145
+ *
146
+ * @param {object|string} usage
147
+ * @returns {string}
148
+ */
149
+ export function renderUsage(usage) {
150
+ if (typeof usage === 'string') {
151
+ return usage.endsWith('\n') ? usage : `${usage}\n`;
152
+ }
153
+ return formatUsage(usage);
154
+ }
155
+
156
+ /**
157
+ * Answer a help request. Returns `true` when help was requested (and printed),
158
+ * `false` when the caller should carry on with its normal path.
159
+ *
160
+ * Never throws on a missing/blank `usage`: a script whose spec renders empty
161
+ * still owes the contract non-empty stdout, so a minimal line is emitted
162
+ * rather than a silent exit that reads as a broken CLI.
163
+ *
164
+ * @param {string[]} argv
165
+ * @param {object|string} usage
166
+ * @param {{ write: (s: string) => void }} [out] Defaults to `process.stdout`.
167
+ * @returns {boolean}
168
+ */
169
+ export function respondToHelp(argv, usage, out = process.stdout) {
170
+ if (!wantsHelp(argv)) return false;
171
+ const text = renderUsage(usage ?? '');
172
+ out.write(text.trim().length > 0 ? text : 'Usage: (no flags documented)\n');
173
+ return true;
174
+ }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import path from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
+ import { respondToHelp } from './cli-usage.js';
19
20
  import { formatCliError } from './error-redactor.js';
20
21
 
21
22
  /**
@@ -37,12 +38,21 @@ export function isDirectInvocation(importMetaUrl) {
37
38
  * `main` is funnelled through either the caller-supplied `onError` callback
38
39
  * or the default handler (prefixed stderr line + `process.exit(exitCode)`).
39
40
  *
41
+ * A `usage` option makes the script self-describing: when the argv carries
42
+ * `--help` / `-h`, the rendered usage block goes to stdout and `main` is
43
+ * **never invoked**. That ordering is the whole point — it makes "`--help`
44
+ * performs no GitHub write, acquires no lease, and mutates no working tree"
45
+ * structurally true for every adopting script, rather than something each
46
+ * `main` has to remember to check before its first side effect.
47
+ *
40
48
  * @param {string} importMetaUrl Caller's `import.meta.url`.
41
49
  * @param {() => Promise<unknown>} main The CLI's main function.
42
50
  * @param {object} [options]
43
51
  * @param {string} [options.source='CLI'] Prefix used in the default error message.
44
52
  * @param {number} [options.exitCode=1] Exit code used by the default error handler.
45
53
  * @param {(err: Error) => void} [options.onError] Full override of the error handler.
54
+ * @param {object|string} [options.usage] Usage spec (or pre-rendered
55
+ * text) printed for `--help`; see `lib/cli-usage.js`.
46
56
  */
47
57
  export function runAsCli(importMetaUrl, main, options = {}) {
48
58
  if (!isDirectInvocation(importMetaUrl)) return;
@@ -52,7 +62,9 @@ export function runAsCli(importMetaUrl, main, options = {}) {
52
62
  onError,
53
63
  propagateExitCode = false,
54
64
  errorPrefix,
65
+ usage,
55
66
  } = options;
67
+ if (usage && respondToHelp(process.argv.slice(2), usage)) return;
56
68
  const promise = main();
57
69
  if (propagateExitCode) {
58
70
  promise.then((code) => process.exit(code ?? 0));
@@ -29,11 +29,19 @@
29
29
  * converted spawn boots on **instead of** the always-loaded
30
30
  * closure, so it is budgeted independently (per-file ≤8KB
31
31
  * ceiling gated by `check-context-budget.js`).
32
+ * - `workflow` / — the `.agents/workflows/**` read-tier (Story #4752),
33
+ * `workflowOnDemand` resolved as each entry point's transitive
34
+ * markdown-link closure by
35
+ * [`workflow-closure.js`](workflow-closure.js): the files
36
+ * an entry point's `mandatoryReads:` frontmatter forces
37
+ * you to read (`workflow`, gated) versus the reachable
38
+ * remainder (`workflowOnDemand`, recorded only).
32
39
  *
33
40
  * A file that could appear in more than one tier is kept in its **highest**
34
41
  * tier only (alwaysLoaded > mandatoryRead > digestVisible > onDemand), so the
35
42
  * arrays partition the doc set with no double-counting. `agentBoot` is disjoint
36
- * from the read-tiers (it lives under `.agents/agents/`, not the doc/rules set).
43
+ * from the read-tiers (it lives under `.agents/agents/`, not the doc/rules set),
44
+ * and so are the workflow tiers (confined to `.agents/workflows/`).
37
45
  *
38
46
  * The closure is discovered by parsing `@`-import references and following
39
47
  * them recursively (cycle-safe via a visited set). A candidate `@`-token only
@@ -47,6 +55,7 @@
47
55
 
48
56
  import nodeFs from 'node:fs';
49
57
  import path from 'node:path';
58
+ import { resolveWorkflowClosures } from './workflow-closure.js';
50
59
 
51
60
  /**
52
61
  * Basename of the always-loaded entry document (the root of the closure).
@@ -212,18 +221,31 @@ export function docsContextPaths(config) {
212
221
  }
213
222
 
214
223
  /**
215
- * Resolve the four documentation read-tiers, each entry `{ path, bytes }`,
224
+ * Resolve the documentation read-tiers, each entry `{ path, bytes }`,
216
225
  * partitioned so no path appears in more than one tier (highest tier wins).
226
+ * `workflowClosure` rides alongside the tiers as the per-entry-point workflow
227
+ * measurement (Story #4752) — its `reachableTotalBytes` is a recorded drift
228
+ * signal, never a gate.
217
229
  *
218
230
  * @param {object} config resolved config (`resolveConfig()` output)
219
231
  * @param {{ root?: string, fs?: FsLike }} [opts]
220
- * @returns {{ tiers: {
221
- * alwaysLoaded: Array<{ path: string, bytes: number }>,
222
- * mandatoryRead: Array<{ path: string, bytes: number }>,
223
- * digestVisible: Array<{ path: string, bytes: number }>,
224
- * onDemand: Array<{ path: string, bytes: number }>,
225
- * agentBoot: Array<{ path: string, bytes: number }>,
226
- * } }}
232
+ * @returns {{
233
+ * tiers: {
234
+ * alwaysLoaded: Array<{ path: string, bytes: number }>,
235
+ * mandatoryRead: Array<{ path: string, bytes: number }>,
236
+ * digestVisible: Array<{ path: string, bytes: number }>,
237
+ * onDemand: Array<{ path: string, bytes: number }>,
238
+ * agentBoot: Array<{ path: string, bytes: number }>,
239
+ * workflow: Array<{ path: string, bytes: number }>,
240
+ * workflowOnDemand: Array<{ path: string, bytes: number }>,
241
+ * },
242
+ * workflowClosure: {
243
+ * mandatoryTotalBytes: number,
244
+ * reachableTotalBytes: number,
245
+ * entryPoints: Array<{ path: string, mandatoryBytes: number, reachableBytes: number }>,
246
+ * },
247
+ * }}
248
+ * @throws {Error} on an unresolvable `mandatoryReads` entry or a mandatory cycle
227
249
  */
228
250
  export function resolveDocTiers(
229
251
  config,
@@ -265,8 +287,29 @@ export function resolveDocTiers(
265
287
  // are standalone system prompts, disjoint from the doc read-tiers.
266
288
  const agentBoot = collect(listAgentDefs(root, fs));
267
289
 
290
+ // 6. workflow: each entry point's transitive markdown-link closure (#4752),
291
+ // split into the gated mandatory set and the recorded on-demand remainder.
292
+ // The walk is confined to `.agents/workflows/**`, and `collect` still
293
+ // de-dupes, so no path is counted against two tiers.
294
+ const closure = resolveWorkflowClosures(root, { fs });
295
+ const workflow = collect(closure.mandatoryFiles.map((e) => e.path));
296
+ const workflowOnDemand = collect(closure.onDemandFiles.map((e) => e.path));
297
+
268
298
  return {
269
- tiers: { alwaysLoaded, mandatoryRead, digestVisible, onDemand, agentBoot },
299
+ tiers: {
300
+ alwaysLoaded,
301
+ mandatoryRead,
302
+ digestVisible,
303
+ onDemand,
304
+ agentBoot,
305
+ workflow,
306
+ workflowOnDemand,
307
+ },
308
+ workflowClosure: {
309
+ mandatoryTotalBytes: closure.mandatoryTotalBytes,
310
+ reachableTotalBytes: closure.reachableTotalBytes,
311
+ entryPoints: closure.entryPoints,
312
+ },
270
313
  };
271
314
  }
272
315
 
@@ -356,7 +356,7 @@ function resolveRiskHeuristics(config = {}) {
356
356
 
357
357
  /**
358
358
  * Ceilings a seed's advisory complexity signals must fit for the plan
359
- * workflow to **suggest** `/deliver-light` at Gate #1 (Story #4741 R3 plan-side
359
+ * workflow to **suggest** the light path at Gate #1 (Story #4741 R3 plan-side
360
360
  * handshake). Framework constants, not operator knobs — mirroring the
361
361
  * conservative intent of `complexity-gate.js`'s `STORY_SHAPE_CEILINGS`
362
362
  * (small, mostly-additive, non-sensitive) but read against the *seed-time*
@@ -364,9 +364,14 @@ function resolveRiskHeuristics(config = {}) {
364
364
  *
365
365
  * The suggestion is **advisory only and never an automatic reroute**: it
366
366
  * surfaces at Gate #1 for the operator to decide, and under `--yes` it is
367
- * recorded on the envelope while planning proceeds unchanged. `/deliver-light`
368
- * is a sibling Story; these ceilings define the plan side of the routing
369
- * handshake independently of it.
367
+ * recorded on the envelope while planning proceeds unchanged.
368
+ *
369
+ * These ceilings are deliberately NOT the ones the light path itself applies
370
+ * (Story #4760). A confirmed suggestion routes into
371
+ * `workflows/helpers/deliver-light.md`, whose gate re-judges the *predicted
372
+ * shape* against `STORY_SHAPE_CEILINGS`. Two checks at two different stages:
373
+ * this one screens a seed, that one decides. Collapsing them would make a
374
+ * confirm a bypass.
370
375
  *
371
376
  * - `maxArtifacts` — enumerated seed items (one artifact each).
372
377
  * - `maxRiskHeuristicHits` — any risk-heuristic hit disqualifies: risk
@@ -381,9 +386,9 @@ const DELIVER_LIGHT_SUGGESTION_CEILINGS = Object.freeze({
381
386
  });
382
387
 
383
388
  /**
384
- * Derive the advisory `/deliver-light` suggestion from a seed's complexity
385
- * signals (Story #4741 AC-6). Pure and total: a malformed / missing signal
386
- * bag fails conservative (not suggested), never throws.
389
+ * Derive the advisory light-path suggestion from a seed's complexity signals
390
+ * (Story #4741 AC-6). Pure and total: a malformed / missing signal bag fails
391
+ * conservative (not suggested), never throws.
387
392
  *
388
393
  * `automatic: false` is part of the contract — the suggestion is surfaced for
389
394
  * the operator, never a silent reroute of a non-interactive run.
@@ -435,16 +440,16 @@ export function buildDeliverLightSuggestion(complexitySignals) {
435
440
  ceilings,
436
441
  reasons: suggested
437
442
  ? [
438
- `seed fits the /deliver-light ceilings (≤${ceilings.maxArtifacts} artifacts, ` +
443
+ `seed fits the light-path ceilings (≤${ceilings.maxArtifacts} artifacts, ` +
439
444
  'no risk-heuristic hits, no sensitive-path classes) — the operator ' +
440
- 'may prefer /deliver-light for this scope',
445
+ 'may prefer /deliver for this scope',
441
446
  ]
442
447
  : reasons,
443
448
  };
444
449
  }
445
450
 
446
451
  /**
447
- * Attach the advisory `/deliver-light` suggestion to a complexity-signals bag
452
+ * Attach the advisory light-path suggestion to a complexity-signals bag
448
453
  * as a **nested** field (Story #4741). Nesting — rather than a new top-level
449
454
  * envelope key — keeps every existing per-mode envelope key set byte-stable
450
455
  * (AC-5): the suggestion is derived from the signals it rides on.
@@ -920,7 +925,7 @@ function extractPriorArtifacts(priorBody) {
920
925
  /**
921
926
  * Build the amendment (delta) envelope — `plan-context --amends #<id>`
922
927
  * (Story #4741 AC-4, R3-A). The heavy-amendment counterpart to routing a
923
- * light amendment through `/deliver-light`: instead of re-interrogating the
928
+ * light amendment through the light path: instead of re-interrogating the
924
929
  * repo from scratch (`buildAuthoringContext`'s codebase snapshot and the BDD /
925
930
  * memory / feedback probes), the envelope composes a DELTA from what already
926
931
  * exists — the prior Story's body, its acceptance criteria (the real