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.
- package/.agents/rules/orchestration-error-handling.md +9 -1
- package/.agents/scripts/lib/audit-suite/audit-rules-reader.js +48 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -26
- package/.agents/scripts/lib/orchestration/complexity-gate.js +61 -13
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +6 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +186 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +21 -3
- package/.agents/workflows/deliver.md +27 -29
- package/.agents/workflows/helpers/deliver-digest.md +126 -0
- package/.agents/workflows/helpers/deliver-reference.md +21 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +6 -0
- package/.agents/workflows/helpers/deliver-story.md +31 -35
- package/docs/CHANGELOG.md +12 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
|
|
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
|
|
579
|
-
*
|
|
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({
|
|
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:
|
|
147
|
+
log: gateLog.log,
|
|
133
148
|
}),
|
|
134
|
-
log:
|
|
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',
|
|
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,
|
|
24
|
-
blocker resolved against its real issue state). You never hand it a graph
|
|
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**.
|
|
27
|
-
|
|
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
|
|
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**,
|
|
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"`
|
|
54
|
-
`agent::
|
|
55
|
-
(
|
|
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
|
|
68
|
-
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
level
|
|
100
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
the only sanctioned no-merge ending, returned only when your own
|
|
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