mandrel 2.11.0 → 2.12.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.
@@ -46,8 +46,16 @@ success output is a per-turn tax on the whole session — not a one-time cost.
46
46
  their drivers — emit them single-line (`JSON.stringify(x)`), never
47
47
  pretty-printed (`null, 2` only adds resident bytes). Pretty output is
48
48
  reserved for explicit opt-in flags (`--pretty`).
49
+ - **Streamed child output counts too (Story #4736).** A script that pipes a
50
+ child process's stdout/stderr through to the caller is emitting that output
51
+ as its own. `single-story-close.js` streamed every close-validation gate —
52
+ the whole of `npm test` included — and blew the budget by ~25× on a *passing*
53
+ close. Capture it to an artifact instead
54
+ ([`single-story-close/gate-log.js`](../scripts/lib/orchestration/single-story-close/gate-log.js)),
55
+ emit the digest, and **replay the tail inline on failure** — the bound is a
56
+ success-path bound, and a red gate's evidence belongs in front of the caller.
49
57
  - **Escape hatch.** `MANDREL_RESULT_DETAIL=inline` restores inline full
50
58
  detail for interactive debugging; scripts using `emitTerseResult` honor it
51
- automatically.
59
+ automatically. `AGENT_LOG_LEVEL=verbose` restores live gate streaming.
52
60
  - **stdout purity is unchanged.** Scripts whose stdout is a machine contract
53
61
  (Story #2278) keep logs on stderr; the digest is the *only* stdout line.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * lib/audit-suite/audit-rules-reader.js — the one synchronous reader of the
3
+ * `audit-rules.json` manifest, memoized for the process lifetime.
4
+ *
5
+ * The manifest is shipped framework configuration resolved from one fixed
6
+ * path per process, and the shape-derivation path reads it once per Story at
7
+ * resolve AND persist — an un-memoized read is pure repeated I/O (measured
8
+ * 221 µs/op raw vs 5.5 µs seamed on the run adhoc-4722-4723 audit). Only a
9
+ * successful parse is cached: a read failure stays a per-call throw so a
10
+ * caller can observe a manifest that becomes readable later. Tests never
11
+ * reach this read — they inject fixture rules through the callers'
12
+ * `injectedRules` seam.
13
+ */
14
+
15
+ import { readFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
18
+
19
+ /** Process-lifetime memo of the parsed manifest (successful parses only). */
20
+ let auditRulesCache = null;
21
+
22
+ /**
23
+ * Read and parse the `audit-rules.json` manifest synchronously from the
24
+ * project's configured `schemasRoot`. Shared by the synchronous, ticket-free
25
+ * readers (`resolveLensTier`, `selectLocalLenses`,
26
+ * `selectSensitivePathClasses`) so the path resolution and read-failure
27
+ * handling live in one place rather than being duplicated per reader.
28
+ *
29
+ * @returns {{ audits?: Record<string, object> }} Parsed manifest.
30
+ * @throws {Error} When the manifest cannot be read or parsed.
31
+ */
32
+ export function readAuditRulesSync() {
33
+ if (auditRulesCache !== null) return auditRulesCache;
34
+ const config = resolveConfig();
35
+ const rulesPath = path.join(
36
+ PROJECT_ROOT,
37
+ getPaths(config).schemasRoot,
38
+ 'audit-rules.json',
39
+ );
40
+ try {
41
+ auditRulesCache = JSON.parse(readFileSync(rulesPath, 'utf8'));
42
+ return auditRulesCache;
43
+ } catch (err) {
44
+ throw new Error(
45
+ `audit-suite: failed to read audit-rules from ${rulesPath}: ${err.message}`,
46
+ );
47
+ }
48
+ }
@@ -26,6 +26,7 @@ import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
26
26
  import { softFailOrThrow } from '../degraded-mode.js';
27
27
  import { gitSpawn } from '../git-utils.js';
28
28
  import { withTimeout } from '../util/with-timeout.js';
29
+ import { readAuditRulesSync } from './audit-rules-reader.js';
29
30
 
30
31
  const DEFAULT_GIT_TIMEOUT_MS = 30000;
31
32
 
@@ -101,32 +102,6 @@ export const LENS_TIERS = Object.freeze(['local', 'cumulative', 'global']);
101
102
  * manifest cannot be read, or the registered entry carries a scope outside
102
103
  * {@link LENS_TIERS}.
103
104
  */
104
- /**
105
- * Read and parse the `audit-rules.json` manifest synchronously from the
106
- * project's configured `schemasRoot`. Shared by the synchronous, ticket-free
107
- * readers ({@link resolveLensTier}, {@link selectLocalLenses}) so the path
108
- * resolution and read-failure handling live in one place rather than being
109
- * duplicated per reader.
110
- *
111
- * @returns {{ audits?: Record<string, object> }} Parsed manifest.
112
- * @throws {Error} When the manifest cannot be read or parsed.
113
- */
114
- function readAuditRulesSync() {
115
- const config = resolveConfig();
116
- const rulesPath = path.join(
117
- PROJECT_ROOT,
118
- getPaths(config).schemasRoot,
119
- 'audit-rules.json',
120
- );
121
- try {
122
- return JSON.parse(readFileSync(rulesPath, 'utf8'));
123
- } catch (err) {
124
- throw new Error(
125
- `audit-suite: failed to read audit-rules from ${rulesPath}: ${err.message}`,
126
- );
127
- }
128
- }
129
-
130
105
  export function resolveLensTier(lens) {
131
106
  const rulesData = readAuditRulesSync();
132
107
 
@@ -35,7 +35,10 @@
35
35
  * acceptance-critic dispatch — while every `single-story-close.js` gate
36
36
  * runs unchanged. The `route::lite` label is a **human-visible hint
37
37
  * only**, never the control signal: a lost label or an unread marker can
38
- * no longer misroute delivery.
38
+ * no longer misroute delivery. Ahead of the shape read sits one
39
+ * shape-independent rule (Story #4736): a **single-Story run** is inline
40
+ * whatever its shape, because sub-agent isolation buys nothing when
41
+ * there is no concurrent sibling to isolate from.
39
42
  *
40
43
  * The shape taxonomy is deliberately the one `review-depth.js` already
41
44
  * applies to the landed diff at close (`deriveChangeLevel` over the
@@ -561,37 +564,68 @@ function deriveStoryRouteFromBody(body, opts = {}) {
561
564
  }
562
565
 
563
566
  /**
564
- * Decide how `/deliver` executes a Story — **from the Story body's own
565
- * shape**, never from the `route::lite` label (Story #4722 AC-4/AC-5).
566
- *
567
- * A lite-shaped Story executes **inline** in the deliver session no
568
- * story-worker sub-agent boot and no fresh acceptance-critic dispatch
569
- * (sub-agent boots are the dominant deliver-phase token cost at trivial
570
- * scope). Everything else a full-shaped body, a missing/unparseable body,
571
- * or the gate disabled via `planning.complexityGate.enabled=false` —
572
- * dispatches as a sub-agent: the conservative default.
567
+ * Best-effort route derivation for reporting, when the *mode* is already
568
+ * pinned by run topology and only `route` remains to be filled in. A body
569
+ * that will not parse yields `null` rather than throwing — the caller is not
570
+ * asking the shape to decide anything.
571
+ *
572
+ * @param {unknown} body
573
+ * @param {{ injectedRules?: object, selectSensitivePathClassesFn?: Function }} opts
574
+ * @returns {ReturnType<typeof deriveStoryShape>|null}
575
+ */
576
+ function routeForReporting(body, opts) {
577
+ if (typeof body !== 'string' || body.trim() === '') return null;
578
+ return deriveStoryRouteFromBody(body, opts);
579
+ }
580
+
581
+ /**
582
+ * Decide how `/deliver` executes a Story.
583
+ *
584
+ * Two independent premises, checked in this order:
585
+ *
586
+ * 1. **Run topology (Story #4736).** A run delivering a *single* Story
587
+ * executes **inline**, whatever its shape. Sub-agent isolation is
588
+ * load-bearing only for CONCURRENT dispatch — two workers sharing a
589
+ * checkout would race on worktrees and branch refs — and a one-Story run
590
+ * has no sibling to race. It therefore pays the spawn premium (a boot is
591
+ * a cache WRITE at full rate, where an inline continuation is a cache read
592
+ * at ~10%; ~$1.43/M vs ~$1.07/M on comparable bench work) for nothing.
593
+ * This is a fact about the run, not about the work, so the shape gate's
594
+ * `enabled` switch — which governs *shape derivation* — does not reach it.
595
+ * 2. **Shape (Story #4722 AC-4/AC-5).** For a multi-Story run, the decision
596
+ * comes **from the Story body's own shape**, never from the `route::lite`
597
+ * label: a lite-shaped Story executes inline; everything else — a
598
+ * full-shaped body, a missing/unparseable body, or the gate disabled via
599
+ * `planning.complexityGate.enabled=false` — dispatches as a sub-agent,
600
+ * the conservative default.
573
601
  *
574
602
  * The label is read only to report hint consistency in `reasons`: with the
575
603
  * label absent (or its write failed) a lite-shaped Story still runs inline,
576
604
  * and with the label present on a full-shaped Story the shape wins.
577
605
  *
578
- * Inline execution removes model-side fan-out only. Every deterministic
579
- * `single-story-close.js` gate runs unchanged regardless of mode — see the
606
+ * Inline execution removes model-side fan-out only it changes **where** the
607
+ * engine runs, never **what** runs. Every deterministic
608
+ * `single-story-close.js` gate, the PR to `main`, and the
609
+ * `story-deliver-terminal` envelope are identical in both modes; see the
580
610
  * module header's non-negotiables.
581
611
  *
582
612
  * @param {{
583
613
  * body?: unknown,
584
614
  * labels?: unknown,
585
615
  * config?: object,
616
+ * storyCount?: unknown,
586
617
  * injectedRules?: object,
587
618
  * selectSensitivePathClassesFn?: Function,
588
- * }} [args]
619
+ * }} [args] `storyCount` is the number of Stories the invoking `/deliver` run
620
+ * resolved. Omitted (or not a positive integer) means "unknown run size",
621
+ * which falls through to the shape decision — never to an assumed 1.
589
622
  * @returns {{ mode: 'inline'|'subagent', reasons: string[], route: ReturnType<typeof deriveStoryShape>|null }}
590
623
  */
591
624
  export function resolveStoryDispatchMode({
592
625
  body,
593
626
  labels,
594
627
  config,
628
+ storyCount,
595
629
  injectedRules,
596
630
  selectSensitivePathClassesFn,
597
631
  } = {}) {
@@ -603,6 +637,20 @@ export function resolveStoryDispatchMode({
603
637
  ? `the ${LITE_ROUTE_LABEL} label is present (hint only — the derived shape is the control signal)`
604
638
  : `the ${LITE_ROUTE_LABEL} label is absent (hint only — the derived shape is the control signal)`;
605
639
 
640
+ if (storyCount === 1) {
641
+ return {
642
+ mode: 'inline',
643
+ reasons: [
644
+ 'single-Story run — execute deliver-story inline; sub-agent isolation is load-bearing only for concurrent dispatch, and a one-Story run has no sibling to race (close gates, PR, and terminal envelope unchanged)',
645
+ hintNote,
646
+ ],
647
+ route: routeForReporting(body, {
648
+ injectedRules,
649
+ selectSensitivePathClassesFn,
650
+ }),
651
+ };
652
+ }
653
+
606
654
  const gate = resolveComplexityGate(config);
607
655
  if (!gate.enabled) {
608
656
  return {
@@ -301,6 +301,7 @@ async function renderRunScopedPlanMetricsLine({
301
301
  * stories: ReturnType<typeof assemblePlanStories>['stories'],
302
302
  * routeDowngradeReason?: string|null,
303
303
  * config?: object,
304
+ * injectedRules?: object,
304
305
  * }} args
305
306
  * @returns {{
306
307
  * route: 'lite'|'full',
@@ -313,6 +314,7 @@ function resolveEffectiveRoute({
313
314
  stories,
314
315
  routeDowngradeReason = null,
315
316
  config = {},
317
+ injectedRules,
316
318
  }) {
317
319
  const verdict = resolvePlannerRouteVerdict({ reason: routeDowngradeReason });
318
320
  if (verdict.route !== 'lite') return null;
@@ -339,6 +341,7 @@ function resolveEffectiveRoute({
339
341
  const derived = deriveStoryShape({
340
342
  changes: story.bodyObject?.changes,
341
343
  acceptance: story.acceptance,
344
+ injectedRules,
342
345
  });
343
346
  return {
344
347
  slug: story.slug,
@@ -455,6 +458,7 @@ export async function reapStalePlanDirs({
455
458
  * sourceTicketOrigin?: 'flag'|'envelope'|'none',
456
459
  * closeSuperseded?: boolean,
457
460
  * routeDowngradeReason?: string|null,
461
+ * injectedRules?: object,
458
462
  * },
459
463
  * }} input
460
464
  */
@@ -483,6 +487,7 @@ export async function runPlanPersist({
483
487
  sourceTicketOrigin = 'none',
484
488
  closeSuperseded = true,
485
489
  routeDowngradeReason = null,
490
+ injectedRules = undefined,
486
491
  } = opts;
487
492
 
488
493
  // Boundary for the plan-metrics summary below: everything this invocation
@@ -566,6 +571,7 @@ export async function runPlanPersist({
566
571
  stories,
567
572
  routeDowngradeReason,
568
573
  config,
574
+ injectedRules,
569
575
  });
570
576
  const isLiteRoute = route?.route === 'lite';
571
577
  if (isLiteRoute) {
@@ -300,6 +300,9 @@ export async function readNativeBlockedBy({
300
300
  * @param {Map<number, number[]>} nativeEdges
301
301
  * @param {number[]} foreignDone Ids outside the set already satisfied.
302
302
  * @param {(msg: string) => void} [warn]
303
+ * @param {object} [injectedRules] Test seam forwarded to the shape
304
+ * derivation — skips the `audit-rules.json` disk read. Production callers
305
+ * omit it (the real manifest, memoized per process, is the default).
303
306
  * @returns {{ kind: string, stories: object[], dag: object[], done: number[] }}
304
307
  */
305
308
  export function buildStoriesEnvelope({
@@ -308,6 +311,7 @@ export function buildStoriesEnvelope({
308
311
  foreignDone = [],
309
312
  warn,
310
313
  config,
314
+ injectedRules,
311
315
  }) {
312
316
  const sorted = [...stories].sort((a, b) => a.id - b.id);
313
317
  const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
@@ -321,13 +325,26 @@ export function buildStoriesEnvelope({
321
325
  // `route::lite` label is a human-visible hint only, never the control
322
326
  // signal: a lost label cannot misroute delivery. Model-side fan-out
323
327
  // only; close gates are untouched.
328
+ //
329
+ // `storyCount` (Story #4736) carries the run's topology into that same
330
+ // decision: a run resolving exactly ONE Story is inline whatever its
331
+ // shape, because the isolation a sub-agent buys only matters against a
332
+ // concurrently-dispatched sibling. It is the resolved set size — not the
333
+ // undelivered remainder — so the mode a caller reads for a given `--ids`
334
+ // list never changes as siblings land mid-run.
324
335
  stories: sorted.map(({ id, title, body, url, labels, state }) => ({
325
336
  id,
326
337
  title,
327
338
  url,
328
339
  labels,
329
340
  state,
330
- dispatchMode: resolveStoryDispatchMode({ body, labels, config }).mode,
341
+ dispatchMode: resolveStoryDispatchMode({
342
+ body,
343
+ labels,
344
+ config,
345
+ storyCount: sorted.length,
346
+ injectedRules,
347
+ }).mode,
331
348
  })),
332
349
  dag: storiesToDag(sorted, nativeEdges, warn),
333
350
  done: [...new Set([...inSetDone, ...foreignDone])].sort((a, b) => a - b),
@@ -0,0 +1,186 @@
1
+ /**
2
+ * single-story-close/gate-log.js — bounded gate output for the close path
3
+ * (Story #4736).
4
+ *
5
+ * ## Why
6
+ *
7
+ * `runCloseValidation` streams every child gate's stdout/stderr line through
8
+ * an injected `log`, and the close phase used to hand it `Logger.info` — whose
9
+ * default sink is `console.log`. A single successful close therefore wrote the
10
+ * whole of `npm test`, the linter, and the baseline checks onto the invoking
11
+ * agent's stdout: ~50KB, over the host's inline tool-result ceiling. The caller
12
+ * got a truncated preview, had to open the persisted file anyway, and re-ran
13
+ * close for a clean envelope — burning the run's most expensive stretch to
14
+ * re-derive output it already had.
15
+ *
16
+ * Story #4708 set the contract this restores compliance with (see
17
+ * `rules/orchestration-error-handling.md` § Output Contract): compact digest
18
+ * plus an on-disk artifact path, ≤ ~2KB on the **default success path**.
19
+ *
20
+ * ## The shape
21
+ *
22
+ * A sink captures every gate line to a log under the gitignored temp tree and
23
+ * emits nothing inline. What happens next depends on the outcome, because the
24
+ * two outcomes want opposite things:
25
+ *
26
+ * - **success** — the caller wants the verdict, not the evidence.
27
+ * {@link GateLogSink#digest} is one line: the pass count and the log path.
28
+ * - **failure** — the evidence IS the point, and making the caller open a
29
+ * file to see why a gate went red just moves the cost.
30
+ * {@link GateLogSink#replay} puts the captured tail back inline.
31
+ *
32
+ * `AGENT_LOG_LEVEL=verbose` opts back into live inline streaming (the
33
+ * "existing log-level control"): the capture still happens, so the artifact is
34
+ * written either way.
35
+ *
36
+ * The sink never throws. A log directory that cannot be written degrades to
37
+ * inline streaming — losing the size bound is strictly better than losing the
38
+ * gate output that says why a close failed.
39
+ */
40
+
41
+ import nodeFs from 'node:fs';
42
+ import path from 'node:path';
43
+
44
+ import { Logger, resolveLevel } from '../../Logger.js';
45
+
46
+ /**
47
+ * How many trailing captured lines {@link GateLogSink#replay} puts back
48
+ * inline. A failed gate's actionable evidence — the assertion, the stack, the
49
+ * summary counts — sits at the end of its output; the head is startup noise.
50
+ * The full text is always in the artifact regardless.
51
+ */
52
+ export const REPLAY_TAIL_LINES = 200;
53
+
54
+ /** Basename of the per-Story gate log inside the temp directory. */
55
+ function logNameFor(storyId) {
56
+ return `close-gates-${storyId ?? 'unknown'}.log`;
57
+ }
58
+
59
+ /**
60
+ * A capturing sink for close-validation gate output.
61
+ *
62
+ * Not exported as a constructor — {@link createGateLogSink} owns the
63
+ * degradation decision, so every instance in the wild has already resolved
64
+ * whether it has a writable artifact.
65
+ */
66
+ class GateLogSink {
67
+ /**
68
+ * @param {{ logPath: string|null, streamInline: boolean, write: (line: string) => void, emit: (line: string) => void }} args
69
+ */
70
+ constructor({ logPath, streamInline, write, emit }) {
71
+ /** Absolute path of the artifact, or `null` when capture is unavailable. */
72
+ this.logPath = logPath;
73
+ /** Whether lines are ALSO echoed inline as they arrive. */
74
+ this.streamInline = streamInline;
75
+ /** Number of lines captured so far. */
76
+ this.lineCount = 0;
77
+ this._write = write;
78
+ this._emit = emit;
79
+ this._tail = [];
80
+ }
81
+
82
+ /**
83
+ * The `log` callable handed to `runCloseValidation` / `buildDefaultGates`.
84
+ * Bound, because it is passed by reference into the gate machinery.
85
+ *
86
+ * @type {(message: string) => void}
87
+ */
88
+ get log() {
89
+ return (message) => {
90
+ const line = String(message ?? '');
91
+ this.lineCount += 1;
92
+ this._tail.push(line);
93
+ if (this._tail.length > REPLAY_TAIL_LINES) this._tail.shift();
94
+ this._write(line);
95
+ if (this.streamInline) this._emit(line);
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The success-path digest: one line, no gate output. Names the artifact so
101
+ * the caller can open it on demand rather than carrying it all session.
102
+ *
103
+ * @returns {string}
104
+ */
105
+ digest() {
106
+ const where = this.logPath
107
+ ? `full gate output → ${this.logPath}`
108
+ : 'full gate output was streamed inline (no artifact could be written)';
109
+ return `${this.lineCount} line(s) of gate output captured; ${where}`;
110
+ }
111
+
112
+ /**
113
+ * Put the captured tail back inline — the failure path, where the evidence
114
+ * is what the caller came for. A no-op when the lines were already streamed
115
+ * inline (verbose, or degraded capture), so nothing is ever printed twice.
116
+ *
117
+ * @returns {number} Lines replayed.
118
+ */
119
+ replay() {
120
+ if (this.streamInline || this._tail.length === 0) return 0;
121
+ const dropped = this.lineCount - this._tail.length;
122
+ if (dropped > 0) {
123
+ this._emit(
124
+ `[close-validation] … ${dropped} earlier line(s) omitted; full output → ${this.logPath}`,
125
+ );
126
+ }
127
+ for (const line of this._tail) this._emit(line);
128
+ return this._tail.length;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Build the gate-output sink for one close run.
134
+ *
135
+ * @param {{
136
+ * storyId: number|null,
137
+ * cwd?: string,
138
+ * logDir?: string,
139
+ * fs?: typeof nodeFs,
140
+ * logger?: { info: (m: string) => void },
141
+ * level?: string,
142
+ * }} [args] `logDir` defaults to `<cwd>/temp/orchestration`; `level` defaults
143
+ * to the live Logger level so `AGENT_LOG_LEVEL=verbose` restores streaming.
144
+ * @returns {GateLogSink}
145
+ */
146
+ export function createGateLogSink({
147
+ storyId = null,
148
+ cwd = process.cwd(),
149
+ logDir,
150
+ fs = nodeFs,
151
+ logger = Logger,
152
+ level,
153
+ } = {}) {
154
+ const emit = (line) => logger.info?.(line);
155
+ const verbose = (level ?? resolveLevel()) === 'verbose';
156
+ const dir = logDir ?? path.join(cwd, 'temp', 'orchestration');
157
+
158
+ let handle = null;
159
+ let logPath = null;
160
+ try {
161
+ fs.mkdirSync(dir, { recursive: true });
162
+ logPath = path.join(dir, logNameFor(storyId));
163
+ // Truncate: each close run owns its artifact outright, so a re-run never
164
+ // hands the reader a file interleaving two runs' gates.
165
+ handle = fs.openSync(logPath, 'w');
166
+ } catch {
167
+ // No artifact — fall back to inline streaming rather than dropping the
168
+ // gate output on the floor.
169
+ return new GateLogSink({
170
+ logPath: null,
171
+ streamInline: true,
172
+ write: () => {},
173
+ emit,
174
+ });
175
+ }
176
+
177
+ const write = (line) => {
178
+ try {
179
+ fs.writeSync(handle, `${line}\n`);
180
+ } catch {
181
+ /* best-effort: a mid-run write failure must not abort the close */
182
+ }
183
+ };
184
+
185
+ return new GateLogSink({ logPath, streamInline: verbose, write, emit });
186
+ }
@@ -24,6 +24,15 @@
24
24
  * the same, with `baseBranch` as the diff anchor and the Story worktree as
25
25
  * the commit target.
26
26
  *
27
+ * Bounded gate output (Story #4736). Every gate line goes to the run's
28
+ * `gate-log.js` sink — an artifact under the gitignored temp tree — instead
29
+ * of straight to the agent's stdout, where a passing `npm test` alone once
30
+ * pushed a successful close past the host's inline tool-result ceiling. A
31
+ * clean run reports one digest line naming the artifact; a failing gate
32
+ * replays its captured tail inline, because that is exactly when the caller
33
+ * needs the evidence in front of them. `AGENT_LOG_LEVEL=verbose` restores
34
+ * live streaming.
35
+ *
27
36
  * `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
28
37
  * are accepted as injected dependencies so the parent CLI's cache-busted
29
38
  * bindings win in tests that mock the upstream module URLs.
@@ -33,6 +42,7 @@ import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-va
33
42
  import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
34
43
  import { Logger } from '../../../Logger.js';
35
44
  import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
45
+ import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
36
46
 
37
47
  /**
38
48
  * Run the close-validation gate chain. Throws on first gate failure.
@@ -60,6 +70,7 @@ import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../s
60
70
  * runCloseValidation?: typeof defaultRunCloseValidation,
61
71
  * buildDefaultGates?: typeof defaultBuildDefaultGates,
62
72
  * runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
73
+ * createGateLogSink?: typeof defaultCreateGateLogSink,
63
74
  * }} args
64
75
  */
65
76
  export async function runCloseValidationPhase({
@@ -73,6 +84,7 @@ export async function runCloseValidationPhase({
73
84
  runCloseValidation = defaultRunCloseValidation,
74
85
  buildDefaultGates = defaultBuildDefaultGates,
75
86
  runScopedFormatAutofix = defaultRunScopedFormatAutofix,
87
+ createGateLogSink = defaultCreateGateLogSink,
76
88
  }) {
77
89
  // Story #4250 — format-autofix self-heal before the check-only gates.
78
90
  // Mirrors the Epic path (story-close/phases/gates.js): the formatter is
@@ -122,6 +134,9 @@ export async function runCloseValidationPhase({
122
134
  'VALIDATE',
123
135
  `Running close-validation gates against baseline ${baseBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
124
136
  );
137
+ // Story #4736 — one sink for both `log` seams (gate construction and gate
138
+ // execution), so nothing in the chain can route around the artifact.
139
+ const gateLog = createGateLogSink({ storyId, cwd });
125
140
  const validation = await runCloseValidation({
126
141
  cwd,
127
142
  worktreePath,
@@ -129,9 +144,9 @@ export async function runCloseValidationPhase({
129
144
  config,
130
145
  baseBranch,
131
146
  cwd: worktreePath || cwd,
132
- log: (m) => Logger.info(m),
147
+ log: gateLog.log,
133
148
  }),
134
- log: (m) => Logger.info(m),
149
+ log: gateLog.log,
135
150
  storyId,
136
151
  // Story #4250 — standalone storyId-anchored evidence keyspace. No
137
152
  // epicId; the standalone flag routes the cache to
@@ -141,10 +156,13 @@ export async function runCloseValidationPhase({
141
156
  if (!validation.ok) {
142
157
  const [first] = validation.failed;
143
158
  const { gate, status, cwd: gateCwd } = first;
159
+ // The evidence is the point on this path: replay the captured tail inline
160
+ // rather than making the caller open a file to learn why close stopped.
161
+ gateLog.replay();
144
162
  throw new Error(
145
163
  `[single-story-close] Gate failed: ${gate.name} (exit ${status})${gateCwd ? ` in ${gateCwd}` : ''}.` +
146
164
  (gate.hint ? ` ${gate.hint}` : ''),
147
165
  );
148
166
  }
149
- progress('VALIDATE', '✅ All gates passed.');
167
+ progress('VALIDATE', `✅ All gates passed. ${gateLog.digest()}`);
150
168
  }
@@ -10,7 +10,9 @@ description:
10
10
  > **Lean spine.** Happy path + gate list. Sequencing edge cases, dispatch
11
11
  > mechanics, lite-route inline execution, checklist threading, ceremony, and
12
12
  > the per-run epilogue live in the on-demand
13
- > [`helpers/deliver-reference.md`](helpers/deliver-reference.md).
13
+ > [`helpers/deliver-reference.md`](helpers/deliver-reference.md). What every
14
+ > delivery always needs is bundled into one read:
15
+ > [`helpers/deliver-digest.md`](helpers/deliver-digest.md) (Story #4736).
14
16
 
15
17
  ## Role
16
18
 
@@ -20,20 +22,22 @@ owns input resolution and sequencing only — every Story runs through
20
22
  `epic/<id>` integration branch, no `--no-ff` wave merges.
21
23
 
22
24
  The dependency graph is **discovered, not declared**: `resolve-stories.js`
23
- reads it from live state (body edges ∪ native GitHub `blocked_by` edges, every
24
- blocker resolved against its real issue state). You never hand it a graph, and
25
+ reads it from live state (body edges ∪ native GitHub `blocked_by` edges, each
26
+ blocker resolved against its real issue state). You never hand it a graph and
25
27
  there is no batch label — which is what lets you deliver Stories **across plan
26
- runs and over time**. The `plan-run::<id>` grouping label is filter metadata
27
- only — never a resolution input (there is no `--run` or `--dep` axis).
28
+ runs and over time**. `plan-run::<id>` is filter metadata, never a resolution
29
+ input.
28
30
  Per-Story routes are **body-derived** too (#4722); `route::lite` is a hint
29
- only.
31
+ only. Ahead of that: a **single-Story run runs the engine inline** whatever the
32
+ shape (#4736) — sub-agent isolation only earns its cost against a concurrent
33
+ sibling.
30
34
 
31
35
  ## Inputs
32
36
 
33
37
  | Invocation | Behavior |
34
38
  | --- | --- |
35
- | `/deliver <storyId>` | Deliver one Story via `helpers/deliver-story.md`. |
36
- | `/deliver <storyId> <storyId> ...` | Resolve the set with `resolve-stories.js`, then sequence by the discovered graph via `stories-wave-tick.js`. Default concurrency is **3**. |
39
+ | `/deliver <storyId>` | Deliver one Story via `helpers/deliver-story.md`, executed **inline in this session** — no `story-worker` spawn. |
40
+ | `/deliver <storyId> <storyId> ...` | Resolve the set with `resolve-stories.js`, then sequence by the discovered graph via `stories-wave-tick.js`, dispatching role-scoped sub-agents. Default concurrency is **3**. |
37
41
 
38
42
  Any named ticket that is not `type::story`, or still carrying an `Epic: #N`
39
43
  footer, is a **hard error** naming the id and the fix (close or re-plan as a v2
@@ -43,18 +47,16 @@ Story). Resolution refuses the whole set rather than silently under-delivering.
43
47
 
44
48
  | Flag | Meaning |
45
49
  | --- | --- |
46
- | `--concurrency <n>` | **Optional** per-run override of the fan-out cap. Omit it to honor `delivery.deliverRunner.concurrencyCap` (config default **3**, including any `.agentrc.local.json` override); pass it **only** for a one-run cap. `1` = sequential. |
50
+ | `--concurrency <n>` | **Optional** per-run override of the fan-out cap. Omit it to honor `delivery.deliverRunner.concurrencyCap` (config default **3**, incl. any `.agentrc.local.json` override); pass **only** for a one-run cap. `1` = sequential. |
47
51
  | `--yes` | Suppress the multi-Story confirmation gate. |
48
52
  | `--steal` | Forwarded to `single-story-init.js` / lease steal. |
49
53
  | `--wait-merge` | Force close-and-land (the default; `delivery.routing.closeAndLand`). |
50
54
  | `--no-wait-merge` | Opt out; stop at `agent::closing` for a human land. |
51
55
 
52
56
  **Operator-merge implies no-wait.** `--no-auto-merge` and
53
- `delivery.ci.autoMerge: "strict"` leave the PR un-armed: the Story rests at
54
- `agent::closing` for the human merge and is **not** flipped to `agent::blocked`
55
- (`--wait-merge` does not override this). A genuine *arm failure* differs — it
56
- still waits and still blocks, because that is a fault to report, not an operator
57
- decision to respect.
57
+ `delivery.ci.autoMerge: "strict"` rest the Story at `agent::closing`, not
58
+ `agent::blocked` a genuine *arm failure* still waits and still blocks
59
+ ([`helpers/deliver-reference.md` § Operator-merge](helpers/deliver-reference.md)).
58
60
 
59
61
  ## Procedure
60
62
 
@@ -64,8 +66,8 @@ decision to respect.
64
66
  present the order in step 2. You do **not** thread them into step 3 — the
65
67
  tick re-resolves the graph itself every beat. Resolution hard-errors
66
68
  (exit 1) on a named id that is not a Story, carries an `Epic: #N` footer, or
67
- whose native edges cannot be read — a missing gate would co-dispatch a Story
68
- against an unlanded blocker.
69
+ whose native edges cannot be read — a missing gate would co-dispatch against
70
+ an unlanded blocker.
69
71
 
70
72
  2. **Confirm (N>1).** Present the order; wait unless `--yes`.
71
73
 
@@ -78,10 +80,8 @@ decision to respect.
78
80
  ```
79
81
 
80
82
  **Do not add `--concurrency` unless the operator explicitly asked for a
81
- per-run cap.** Omitting it lets the tick resolve the cap from
82
- `delivery.deliverRunner.concurrencyCap` including a `.agentrc.local.json`
83
- override. An explicit `--concurrency <n>` wins over config for that run, so a
84
- filled-in literal (e.g. `3`) silently defeats the operator's override.
83
+ per-run cap** an explicit value wins over config, so a filled-in literal
84
+ silently defeats a `.agentrc.local.json` override (see Flags).
85
85
 
86
86
  Each beat re-probes live state to derive done / in-flight itself; you never
87
87
  compute them (Story #4594). `--dispatched` is the one thing you must supply —
@@ -129,10 +129,10 @@ review depth reading the same level) and the mechanism table:
129
129
 
130
130
  ## Reading a Story's outcome
131
131
 
132
- Each Story's delivery ends in exactly one schema-validated terminal envelope
133
- ([`story-deliver-terminal.schema.json`](../schemas/story-deliver-terminal.schema.json),
134
- Story #4543) — `landed` | `pending` | `blocked` | `failed`, the SSOT for the
135
- shape; this workflow does not restate its fields.
132
+ Each Story's delivery ends in exactly one schema-validated terminal envelope
133
+ `landed` | `pending` | `blocked` | `failed`. Statuses, exits, and fields:
134
+ [`helpers/deliver-digest.md`](helpers/deliver-digest.md) § 5, over the shipped
135
+ [schema](../schemas/story-deliver-terminal.schema.json) (Story #4543).
136
136
 
137
137
  `pending` is **not** a failure: the bounded merge wait expired with the PR
138
138
  healthy (or a human owns the merge), nothing was mutated, and the
@@ -146,11 +146,9 @@ For a Story in an unclear state — including the merged-but-label-stale one a
146
146
 
147
147
  ## Constraints
148
148
 
149
- - **Land or block — never a silent local build.** Worktrees, `story-<id>`
150
- branches, close-validation, and PR-to-`main` are the only sanctioned delivery
151
- mechanism. Attended delivers default to close-and-land
152
- (`delivery.routing.closeAndLand: true`); use `--no-wait-merge` only when a
153
- human lands the PR.
149
+ - **Land or block — never a silent local build** (digest § 2). Attended
150
+ delivers default to close-and-land (`delivery.routing.closeAndLand: true`);
151
+ use `--no-wait-merge` only when a human lands the PR.
154
152
  - `/deliver` never plans — tickets come from [`/plan`](plan.md). The router
155
153
  performs no git/label mutations; `deliver-story` owns every script.
156
154
 
@@ -0,0 +1,126 @@
1
+ ---
2
+ description: >-
3
+ The deliver path's one bundled framework read (Story #4736). Carries what
4
+ every Story delivery always needs — dispatch decision, engine invariants,
5
+ the change-set/ceremony incantation, the acceptance-eval gate, and the
6
+ terminal envelope contract — so the engine reads one file instead of
7
+ re-reading the helper/schema set each session.
8
+ ---
9
+
10
+ # Deliver digest (read once per session)
11
+
12
+ > **Bundle, not a procedure.** [`deliver-story.md`](deliver-story.md) is still
13
+ > the steps. This file is the material those steps referenced across five
14
+ > separate files and a JSON schema — bundled so one read covers the whole happy
15
+ > path. Situational material (lease preflight, recovery routers, merge-wait
16
+ > budgets, CI remediation) stays on demand in
17
+ > [`deliver-story-reference.md`](deliver-story-reference.md) and
18
+ > [`deliver-reference.md`](deliver-reference.md); read those **only** when an
19
+ > envelope or a failure routes you there.
20
+
21
+ ## 1. Dispatch — where the engine runs
22
+
23
+ Read `stories[].dispatchMode` from the `resolve-stories.js` envelope. Two
24
+ rules produce it, in order:
25
+
26
+ 1. **Run topology (#4736).** A run resolving **one** Story is `inline`
27
+ whatever its shape — sub-agent isolation is load-bearing only against a
28
+ *concurrent* sibling racing the same checkout, and a one-Story run has none.
29
+ 2. **Body shape (#4722).** In a multi-Story run, a lite-shaped body is
30
+ `inline`; a full-shaped body, an unparseable one, or a footprint touching a
31
+ sensitive-path class is `subagent`. The `route::lite` label is a
32
+ human-visible hint, never the control signal.
33
+
34
+ `inline` removes model-side fan-out only — no `story-worker` boot, no fresh
35
+ acceptance-critic spawn. **`subagent` and `inline` run the same engine**: same
36
+ gates, same PR to `main`, same terminal envelope, byte for byte.
37
+
38
+ ## 2. Engine invariants
39
+
40
+ | Trait | Contract |
41
+ | --- | --- |
42
+ | Ticket type | `type::story` only; an `Epic: #N` footer means **stop and re-plan** |
43
+ | Branch | `story-<id>`, seeded from `project.baseBranch` (`main`) |
44
+ | Merge target | `main` via PR (squash + required checks) — never a direct push |
45
+ | Integration branch | **None** — no `epic/<id>`, no `--no-ff` wave merge |
46
+ | Gates | Every close gate runs regardless of route; no route bypasses one |
47
+ | State | Only via `update-ticket-state.js --ticket <id> --state <state>` |
48
+ | Paths | Prefix every path-based tool with the absolute `workCwd` — `cd` does not scope them |
49
+
50
+ **Land or block.** Worktree → `story-<id>` → close-validation → PR to `main` is
51
+ the only sanctioned landing. A silent local build is not a delivery.
52
+
53
+ ## 3. Change set — computed once, handed to everyone
54
+
55
+ One enumeration per Story (#4593). A critic that re-runs its own `git diff`
56
+ can score a different set than the one that routed it:
57
+
58
+ ```bash
59
+ node --input-type=module -e '
60
+ import { computeChangeSet } from "<main-repo>/.agents/scripts/lib/orchestration/change-set.js";
61
+ const { files } = computeChangeSet({ baseRef: "main", headRef: "story-<storyId>" });
62
+ console.log(JSON.stringify(files));
63
+ '
64
+ ```
65
+
66
+ Derive the level with `deriveChangeLevel`
67
+ ([`review-depth.js`](../../scripts/lib/orchestration/review-depth.js)) over
68
+ that one list: a sensitive path registered in `audit-rules.json` → `high`, none
69
+ → `low`, an unenumerable diff (`files === null`) → `null`. Resolve
70
+ fresh-vs-inline critics with `resolveCeremonyForRisk`
71
+ ([`ceremony-routing.js`](../../scripts/lib/orchestration/ceremony-routing.js)):
72
+ `minimal` → always inline, `strict` → always fresh, `standard` → `high`/`null`
73
+ → fresh and `low` → inline unless the `freshCriticSampleRate` floor forces
74
+ fresh. An `inline` dispatch mode overrides all of it to inline critics. Close's
75
+ `review-depth.js` reads the same derived level, so the two cannot disagree.
76
+
77
+ ## 4. Acceptance self-eval (Step 1a, required)
78
+
79
+ **One verdict-owner per cluster** (#4723) — the fresh critic *or* the inline
80
+ self-eval, named by `verdictOwner`, never both and never a warm-up pass. It
81
+ scores each `acceptance[]` item against the change set above, with `verify[]`
82
+ output as evidence. Bounded by `delivery.acceptanceEval.maxRounds` (default 2).
83
+ Then score the authored verdict:
84
+
85
+ ```bash
86
+ node <main-repo>/.agents/scripts/acceptance-eval.js \
87
+ --story <storyId> --verdict <verdict-path>
88
+ ```
89
+
90
+ `proceed` → close. `redraft` → one more round inside the cap. `block` → **do
91
+ not close**: post a `friction` comment and flip `agent::blocked`.
92
+ Per-round mechanics: [`acceptance-self-eval.md`](acceptance-self-eval.md).
93
+
94
+ ## 5. Terminal envelope — the return contract
95
+
96
+ `single-story-close.js` emits exactly one envelope on stdout between
97
+ `--- STORY DELIVER TERMINAL ---` markers, schema-validated against
98
+ [`story-deliver-terminal.schema.json`](../../schemas/story-deliver-terminal.schema.json)
99
+ (#4543 — the SSOT; read the JSON only when you need a field this table omits).
100
+ Relay it verbatim; never hand-compose one, never substitute prose.
101
+
102
+ | `status` | Exit | Meaning | You do |
103
+ | --- | --- | --- | --- |
104
+ | `landed` | 0 | PR merged, `agent::done`, tail ran (`tail.*: false` degrades the report, not the land) | Relay it. Done. |
105
+ | `pending` | 3 | **Resumable, not a failure** — the bounded wait expired healthy, or a human owns the merge. Nothing was mutated. | Run `nextCommand`. |
106
+ | `blocked` | 1 | Hard block; `blocked.blockClass` names it | `checks-failed` → fix + resume; else relay |
107
+ | `failed` | 1 | A phase crashed; `phase` names which | Diagnose, fix, re-run close |
108
+
109
+ Required fields: `kind` (`story-deliver-terminal`), `storyId`, `status`,
110
+ `phase`, `elapsedSeconds`, `nextCommand`. `phase` is one of `init`,
111
+ `wrong-tree-guard`, `close-validation`, `base-sync`, `push`, `pull-request`,
112
+ `code-review`, `auto-merge`, `confirm-merge`, `post-land`, `done`. `gates`
113
+ reports every gate as `passed` / `failed` / `skipped` — a skipped gate is
114
+ reported, never omitted, so a missing gate is never read as a passing one.
115
+
116
+ **Gate output is captured, not streamed (#4736).** Close writes gate lines to
117
+ `temp/orchestration/close-gates-<storyId>.log` and reports a one-line digest on
118
+ success; a failed gate replays its tail inline. `AGENT_LOG_LEVEL=verbose`
119
+ restores live streaming.
120
+
121
+ ## 6. When to leave this file
122
+
123
+ - Unclear state / a re-run refusal → `deliver-recover.js --story <id>` (read-only).
124
+ - Lease, sweep, worktree-scope detail → [`deliver-story-reference.md`](deliver-story-reference.md).
125
+ - CI red after the PR opens → [`rules/ci-remediation.md`](../../rules/ci-remediation.md).
126
+ - Sequencing, epilogue, checklist threading → [`deliver-reference.md`](deliver-reference.md).
@@ -51,6 +51,16 @@ probe logs a warning and leans on init's lease refusal alone.
51
51
 
52
52
  ## Dispatch mechanics (role-scoped by default)
53
53
 
54
+ **A single-Story run executes inline (Story #4736).** Sub-agent isolation is
55
+ load-bearing only for **concurrent** dispatch — two workers sharing a checkout
56
+ would race on worktrees and branch refs — so a run resolving exactly one Story
57
+ has no sibling to isolate from and pays the spawn premium for nothing (a boot is
58
+ a cache write at full rate; an inline continuation is a cache read at ~10%).
59
+ `resolve-stories.js` already reports it: a one-id run comes back with
60
+ `dispatchMode: "inline"` whatever the Story's shape. Role-scoped spawning is
61
+ retained in full for multi-Story waves, and the rule changes **where** the
62
+ engine runs, never what runs — gates, PR, and terminal envelope are identical.
63
+
54
64
  **Lite-shaped Stories execute inline (Story #4722).** Before spawning anything,
55
65
  read the Story's `dispatchMode` from the resolver envelope
56
66
  (`stories[].dispatchMode`, derived by `resolveStoryDispatchMode` in
@@ -114,6 +124,17 @@ execute it directly, in this turn, threading the same `docsDigestPath` /
114
124
  content, execute directly without a re-read turn. The engine, gates, and
115
125
  terminal envelope are identical either way — only the isolation differs.
116
126
 
127
+ ## Operator-merge implies no-wait
128
+
129
+ `--no-auto-merge` and `delivery.ci.autoMerge: "strict"` leave the PR
130
+ deliberately un-armed: there is nothing for close to land, so the Story rests
131
+ at `agent::closing` for the human merge and is **not** flipped to
132
+ `agent::blocked` — `--wait-merge` does not override this, because the operator
133
+ owning the merge is a decision to respect, not a fault to report. A genuine
134
+ *arm failure* is the opposite case: nobody chose it, so close still waits and
135
+ still blocks. That asymmetry is what keeps the must-land contract intact
136
+ without misfiling deliberate human merges as blocks.
137
+
117
138
  ## Per-run epilogue (N>1)
118
139
 
119
140
  Once the sequence reports `epilogueDue: true` (every Story done), keyed on the
@@ -341,6 +341,12 @@ The `single-story-close.js` script, in order:
341
341
 
342
342
  1. Runs the close-validation gates against `baseBranch` as the baseline.
343
343
  On any gate failure it throws — the operator fixes and re-runs close.
344
+ **Gate output is captured, not streamed (Story #4736).** Every gate line
345
+ goes to `temp/orchestration/close-gates-<storyId>.log`; a clean run reports
346
+ one digest line naming that artifact, and a **failed** gate replays its
347
+ captured tail inline so the evidence is in front of you without opening a
348
+ file. Read the artifact when you need the full text — or re-run under
349
+ `AGENT_LOG_LEVEL=verbose` for live streaming.
344
350
  1a. **Syncs the Story branch from `origin/<baseBranch>`** before push
345
351
  (Story #2580). Runs `git fetch origin <baseBranch>` followed by
346
352
  `git merge --no-edit origin/<baseBranch>` inside the worktree. This
@@ -11,6 +11,13 @@ description:
11
11
  > reference detail lives in
12
12
  > [`deliver-story-reference.md`](deliver-story-reference.md) ("reference"
13
13
  > below); consult on demand. Invoked by [`/deliver`](../deliver.md).
14
+ >
15
+ > **Read [`deliver-digest.md`](deliver-digest.md) once, first.** It is the
16
+ > one bundled read of what every delivery needs — dispatch decision, engine
17
+ > invariants, the change-set/ceremony incantation, the acceptance-eval gate,
18
+ > and the terminal-envelope contract — replacing the per-session re-reads of
19
+ > the helper set and `story-deliver-terminal.schema.json` (Story #4736). The
20
+ > steps below cite it as "digest § N" rather than restating it.
14
21
 
15
22
  ## Overview
16
23
 
@@ -74,15 +81,10 @@ One branch, one PR to `main`, commits against the inline `acceptance[]` /
74
81
 
75
82
  ### Step 1a — Bounded acceptance self-eval loop (**required**)
76
83
 
77
- Follow the single-homed include
78
- [`acceptance-self-eval.md`](acceptance-self-eval.md) (single verdict-owner,
79
- `verify[]`-as-evidence, proceed / redraft / block). Gate invocation (omit
80
- `--epic`):
81
-
82
- ```bash
83
- node <main-repo>/.agents/scripts/acceptance-eval.js \
84
- --story <storyId> --verdict <verdict-path>
85
- ```
84
+ Run the loop and score it with `acceptance-eval.js` — **digest § 4** carries
85
+ the invocation and the proceed / redraft / block contract; per-round critic
86
+ mechanics live in the single-homed include
87
+ [`acceptance-self-eval.md`](acceptance-self-eval.md).
86
88
 
87
89
  **`proceed`** → Step 2 then Step 3. **`block`** → **do not close**: post a
88
90
  `friction` comment and flip `agent::blocked` — commands and the
@@ -92,16 +94,12 @@ node <main-repo>/.agents/scripts/acceptance-eval.js \
92
94
  ## Step 2 — Ceremony (profile + derived level)
93
95
 
94
96
  Ceremony is `delivery.routing.ceremonyProfile` × the **derived change
95
- level** — never a planner-authored verdict (Story #4542).
96
- **Compute the change set once** (Story #4593) with the shared enumerator
97
- [`computeChangeSet`](../../scripts/lib/orchestration/change-set.js) the
98
- same module close uses and hand that one list to every critic. Derive the
99
- level with `deriveChangeLevel`, resolve fresh-vs-inline critics with
100
- `resolveCeremonyForRisk` (`ceremony-routing.js`); a lite Story runs inline
101
- regardless (exact incantation and routing rules: reference § Step 2).
102
- Hard gates (lint / test / format / coverage / CRAP / maintainability)
103
- always run in Step 3 — the derived level never disables them; do **not**
104
- pre-run the full close chain here.
97
+ level** — never a planner-authored verdict (Story #4542). **Digest § 3** is
98
+ the incantation: compute the change set once (Story #4593), derive the level,
99
+ resolve fresh-vs-inline critics with `ceremony-routing.js`; a lite Story runs
100
+ inline regardless (routing edge cases: reference § Step 2). Hard gates always
101
+ run in Step 3 — the derived level never disables them; do **not** pre-run the
102
+ close chain here.
105
103
 
106
104
  ## Step 3 — Close and land (`single-story-close.js`)
107
105
 
@@ -110,15 +108,12 @@ node <main-repo>/.agents/scripts/single-story-close.js --story <storyId> --cwd <
110
108
  ```
111
109
 
112
110
  **The whole delivery tail** — gates, PR, merge wait, `agent::done` flip,
113
- post-land tail in one process. Run it and **branch on the terminal
114
- envelope's `status`** (Story #4543):
115
-
116
- | `status` | Exit | Meaning | You do |
117
- | --- | --- | --- | --- |
118
- | `landed` | 0 | PR merged, `agent::done`, tail ran (`tail.*: false` degrades the report, not the land). | Relay the envelope (Step 7). |
119
- | `pending` | 3 | **Resumable, not a failure** — wait expired healthy, or a human owns the merge. | Run `nextCommand` until resolved. |
120
- | `blocked` | 1 | Hard block; `blocked.blockClass` names it. | `checks-failed` → Step 4; else relay. |
121
- | `failed` | 1 | A phase crashed; `phase` names which. | Diagnose, fix, re-run close. |
111
+ post-land tail in one process. Run it and **branch on the terminal envelope's
112
+ `status`** per the table in **digest § 5** (`landed` → Step 7; `pending` → run
113
+ `nextCommand`; `blocked`/`checks-failed` → Step 4; `failed` → diagnose and
114
+ re-run). Gate output is captured to
115
+ `temp/orchestration/close-gates-<storyId>.log` a clean run prints a digest
116
+ line, a red gate replays its tail inline (Story #4736).
122
117
 
123
118
  Internals (gate order, base-sync, auto-merge arming), the merge-wait
124
119
  budgets, the slow-CI **async** confirm mode (Story #4698 — launch the
@@ -143,13 +138,13 @@ recovery path **only** when the envelope routes you there:
143
138
 
144
139
  ## Step 7 — Return contract (**required as a sub-agent**) {#return-contract}
145
140
 
146
- The contract is the shipped schema
147
- [`story-deliver-terminal.schema.json`](../../schemas/story-deliver-terminal.schema.json)
148
- the SSOT for every field (Story #4543). End your turn by relaying the
149
- validated envelope close emits between its `--- STORY DELIVER TERMINAL ---`
150
- markers — never free-form prose, never a hand-composed object. `pending` is
151
- the only sanctioned no-merge ending, returned only when your own budget is
152
- exhausted (Story #1553). Reference § Step 7.
141
+ End your turn by relaying the validated envelope close emits between its
142
+ `--- STORY DELIVER TERMINAL ---` markers — never free-form prose, never a
143
+ hand-composed object. Statuses, exits, and required fields: **digest § 5**
144
+ (whose SSOT is the shipped
145
+ [schema](../../schemas/story-deliver-terminal.schema.json), Story #4543).
146
+ `pending` is the only sanctioned no-merge ending, returned only when your own
147
+ budget is exhausted (Story #1553). Reference § Step 7.
153
148
 
154
149
  ## Recovering a stranded Story {#recover}
155
150
 
@@ -175,6 +170,7 @@ reuses an open PR).
175
170
 
176
171
  ## See also
177
172
 
173
+ - [`deliver-digest.md`](deliver-digest.md) — the one bundled framework read.
178
174
  - [`/deliver`](../deliver.md) — unified entry point.
179
175
  - [`deliver-story-reference.md`](deliver-story-reference.md) — all on-demand
180
176
  detail.
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.12.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.11.0...mandrel-v2.12.0) (2026-07-24)
6
+
7
+
8
+ ### Performance
9
+
10
+ * **orchestration:** cut deliver-path cost — inline single-Story dispatch, bounded close output, bundled reads (refs [#4736](https://github.com/dsj1984/mandrel/issues/4736)) ([#4737](https://github.com/dsj1984/mandrel/issues/4737)) ([81e62a8](https://github.com/dsj1984/mandrel/commit/81e62a87ca49add2aeb5b5a6b9ac11014edc3374))
11
+
12
+
13
+ ### Changed
14
+
15
+ * **orchestration:** thread injectedRules seam through route composition layers ([#4734](https://github.com/dsj1984/mandrel/issues/4734)) ([6e1daad](https://github.com/dsj1984/mandrel/commit/6e1daadbc24210fdb34e4892c0077256f2ccd051))
16
+
5
17
  ## [2.11.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.10.0...mandrel-v2.11.0) (2026-07-23)
6
18
 
7
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",