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
@@ -129,4 +129,35 @@ async function main() {
129
129
  process.exit(exitCode);
130
130
  }
131
131
 
132
- runAsCli(import.meta.url, main, { source: 'git-cleanup' });
132
+ runAsCli(import.meta.url, main, {
133
+ source: 'git-cleanup',
134
+ usage: {
135
+ invocation:
136
+ 'node .agents/scripts/git-cleanup.js [--execute] [--yes] [--json] [phase flags] [filters]',
137
+ summary:
138
+ 'Tidy the local checkout in four phases — fast-forward the base branch, prune stale remote refs, reap merged branches, triage stashes. Dry-run unless --execute.',
139
+ flags: [
140
+ ['--execute', 'Perform the mutations (default is a dry run).'],
141
+ ['--dry-run', 'Force a dry run even alongside --execute.'],
142
+ ['--yes', 'Skip the interactive confirmation prompts.'],
143
+ ['--json', 'Emit the plan/result envelope as JSON.'],
144
+ ['--remote', 'Also delete the matching remote branches.'],
145
+ ['--fast-forward-main', 'Run only the fast-forward-base phase.'],
146
+ ['--prune-remotes', 'Run only the prune-remotes phase.'],
147
+ ['--branches', 'Run only the merged-branch reap phase.'],
148
+ ['--stashes', 'Run only the stash-triage phase.'],
149
+ [
150
+ '--include <glob>',
151
+ 'Only consider branches matching the glob (repeatable).',
152
+ ],
153
+ [
154
+ '--exclude <glob>',
155
+ 'Never consider branches matching the glob (repeatable).',
156
+ ],
157
+ ['--drop-stashes <ref>', 'Stash ref approved for dropping (repeatable).'],
158
+ ['--base <branch>', 'Base branch (default: project.baseBranch).'],
159
+ ['--cwd <path>', 'Repository root (default: process cwd).'],
160
+ ],
161
+ notes: ['With no phase flag, every phase runs in order.'],
162
+ },
163
+ });
@@ -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
@@ -61,6 +61,15 @@ export const TERMINAL_EXIT_CODES = Object.freeze({
61
61
  pending: 3,
62
62
  blocked: 1,
63
63
  failed: 1,
64
+ /**
65
+ * `escalated` reuses **2**, the code `/deliver-light` already documents for
66
+ * "the gate did not proceed light" — the escalation is that outcome made
67
+ * terminal, not a new one, so giving it a fresh code would fork a vocabulary
68
+ * callers already branch on. It stays distinct from `landed` (nothing was
69
+ * delivered) and from `blocked`/`failed` (nothing is wrong — the work simply
70
+ * belongs to `/plan`).
71
+ */
72
+ escalated: 2,
64
73
  });
65
74
 
66
75
  export const TERMINAL_STATUSES = Object.freeze([
@@ -68,13 +77,38 @@ export const TERMINAL_STATUSES = Object.freeze([
68
77
  'pending',
69
78
  'blocked',
70
79
  'failed',
80
+ 'escalated',
71
81
  ]);
72
82
 
83
+ /** Cap on the prompt echoed into an escalation's `/plan` next command. */
84
+ const PLAN_PROMPT_MAX = 200;
85
+
86
+ /**
87
+ * Render an operator prompt safe to sit inside the double quotes of the
88
+ * `/plan "<prompt>"` next command: collapse newlines (a next command is one
89
+ * line by contract), escape backslashes and double quotes so the quoting
90
+ * cannot be broken out of, and cap the length so a long prompt does not turn
91
+ * the envelope into a transcript. Total — a non-string yields the empty
92
+ * string, which the escalation builder rejects rather than emitting.
93
+ *
94
+ * @param {unknown} prompt
95
+ * @returns {string}
96
+ */
97
+ function quoteForPlan(prompt) {
98
+ const text =
99
+ typeof prompt === 'string' ? prompt.replace(/\s+/g, ' ').trim() : '';
100
+ const capped =
101
+ text.length > PLAN_PROMPT_MAX
102
+ ? `${text.slice(0, PLAN_PROMPT_MAX - 1).trimEnd()}…`
103
+ : text;
104
+ return capped.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
105
+ }
106
+
73
107
  /**
74
108
  * The shared next-command vocabulary. Every producer of a "what now?"
75
- * answer — the `pending` terminal envelope and `deliver-recover.js`
76
- * builds its command from here, so the two surfaces never drift into
77
- * naming different commands for the same observed state.
109
+ * answer — the `pending` terminal envelope, an `escalated` one, and
110
+ * `deliver-recover.js` — builds its command from here, so the surfaces never
111
+ * drift into naming different commands for the same observed state.
78
112
  */
79
113
  export const NEXT_COMMANDS = Object.freeze({
80
114
  /**
@@ -104,6 +138,18 @@ export const NEXT_COMMANDS = Object.freeze({
104
138
  /** Probe a stranded Story and print its single next command. */
105
139
  recover: (storyId) =>
106
140
  `node .agents/scripts/deliver-recover.js --story ${storyId}`,
141
+ /**
142
+ * Hand over-scope work to `/plan` — the next command of an `escalated`
143
+ * terminal (Story #4746).
144
+ *
145
+ * The only entry here that is a slash command rather than a script, and
146
+ * deliberately so: the other entries resume a Story that exists, while this
147
+ * one names work that has no Story yet and needs planning before it can have
148
+ * one. It is quoted for a shell but addressed to a **fresh session** — see
149
+ * the workflow's escalation section for why running it in the escalating
150
+ * session is forbidden.
151
+ */
152
+ escalateToPlan: (prompt) => `/plan "${quoteForPlan(prompt)}"`,
107
153
  });
108
154
 
109
155
  /** @type {Function|null} */
@@ -165,8 +211,9 @@ function compact(obj) {
165
211
  * this replaces.
166
212
  *
167
213
  * @param {object} args
168
- * @param {number} args.storyId
169
- * @param {'landed'|'pending'|'blocked'|'failed'} args.status
214
+ * @param {number|null} args.storyId `null` only for an `escalated` terminal,
215
+ * which by construction never authored a Story.
216
+ * @param {'landed'|'pending'|'blocked'|'failed'|'escalated'} args.status
170
217
  * @param {string} args.phase
171
218
  * @param {string} [args.storyBranch]
172
219
  * @param {string} [args.baseBranch]
@@ -175,6 +222,7 @@ function compact(obj) {
175
222
  * @param {object|null} [args.tail]
176
223
  * @param {object|null} [args.blocked]
177
224
  * @param {object|null} [args.failure]
225
+ * @param {object|null} [args.escalation]
178
226
  * @param {string|null} [args.nextCommand]
179
227
  * @param {number} args.elapsedSeconds
180
228
  * @param {object|null} [args.waitBudget]
@@ -192,6 +240,7 @@ export function buildTerminalEnvelope({
192
240
  tail,
193
241
  blocked,
194
242
  failure,
243
+ escalation,
195
244
  nextCommand,
196
245
  elapsedSeconds = 0,
197
246
  waitBudget,
@@ -199,7 +248,10 @@ export function buildTerminalEnvelope({
199
248
  }) {
200
249
  const envelope = compact({
201
250
  kind: TERMINAL_ENVELOPE_KIND,
202
- storyId: Number(storyId),
251
+ // `Number(null)` is 0, which would quietly satisfy nothing and confuse
252
+ // everything — a nullish storyId stays null and lets the schema decide
253
+ // whether this status is allowed to omit one.
254
+ storyId: storyId === null || storyId === undefined ? null : Number(storyId),
203
255
  status,
204
256
  phase,
205
257
  storyBranch: storyBranch ?? null,
@@ -209,6 +261,7 @@ export function buildTerminalEnvelope({
209
261
  tail: tail ?? null,
210
262
  blocked: blocked ?? null,
211
263
  failure: failure ?? null,
264
+ escalation: escalation ?? null,
212
265
  nextCommand: nextCommand ?? null,
213
266
  elapsedSeconds: Math.max(0, Number(elapsedSeconds) || 0),
214
267
  waitBudget: waitBudget ?? null,
@@ -225,6 +278,69 @@ export function buildTerminalEnvelope({
225
278
  return envelope;
226
279
  }
227
280
 
281
+ /**
282
+ * Build the `escalated` terminal — the one envelope emitted before a Story
283
+ * exists (Story #4746).
284
+ *
285
+ * `/deliver-light`'s suitability gate already decided correctly when it
286
+ * overrode a `lite` self-verdict on shape; what it lacked was an outcome a
287
+ * session could not walk past. A mandrel-bench 2.13.0 light-arm run did
288
+ * exactly that — it read the gate's `escalate-plan`, then invoked `/plan`
289
+ * in the same session and delivered. The continuation was not harmless:
290
+ * planning inside a session already framed as small work authored ONE Story
291
+ * against the scenario's 3-5 contract, where a fresh `/plan` session on the
292
+ * identical seed authored four. Escalation silently produced the very
293
+ * under-decomposition the guard exists to prevent.
294
+ *
295
+ * So the outcome is a validated envelope with its own exit code, naming the
296
+ * `/plan` command that owns the work, and asserting per artifact that nothing
297
+ * was started. Every guarantee here is enforced by the schema rather than by
298
+ * prose: `storyId` must be null, `escalation.created.*` are pinned `false`.
299
+ *
300
+ * @param {{
301
+ * prompt: string,
302
+ * reasons?: string[],
303
+ * elapsedSeconds?: number,
304
+ * timestamp?: string,
305
+ * }} args
306
+ * @returns {object} The validated `escalated` envelope.
307
+ */
308
+ export function buildEscalationTerminal({
309
+ prompt,
310
+ reasons,
311
+ elapsedSeconds = 0,
312
+ timestamp,
313
+ }) {
314
+ const quoted = quoteForPlan(prompt);
315
+ if (quoted === '') {
316
+ // An escalation whose next command is `/plan ""` hands the operator
317
+ // nothing — the same walk-past-able non-outcome in envelope clothing.
318
+ throw new TypeError(
319
+ 'buildEscalationTerminal: a non-empty prompt is required — the escalated terminal exists to name the /plan invocation that owns the work',
320
+ );
321
+ }
322
+ const recorded = (Array.isArray(reasons) ? reasons : []).filter(
323
+ (r) => typeof r === 'string' && r.trim() !== '',
324
+ );
325
+ return buildTerminalEnvelope({
326
+ storyId: null,
327
+ status: 'escalated',
328
+ phase: 'suitability-gate',
329
+ escalation: {
330
+ reasons:
331
+ recorded.length > 0
332
+ ? recorded
333
+ : ['predicted scope exceeds the light ceilings — escalate to /plan'],
334
+ // Not computed from anything: the escalation path returns before the
335
+ // receipt/init call sites, so these are the assertion that it did.
336
+ created: { receiptStory: false, storyBranch: false, worktree: false },
337
+ },
338
+ nextCommand: NEXT_COMMANDS.escalateToPlan(prompt),
339
+ elapsedSeconds,
340
+ ...(timestamp === undefined ? {} : { timestamp }),
341
+ });
342
+ }
343
+
228
344
  /**
229
345
  * Resolve the process exit code for a terminal envelope.
230
346
  *