mandrel 2.11.0 → 2.13.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.
@@ -17,6 +17,12 @@
17
17
  * --tickets 123[,456…] Analyze existing issue(s) into proper
18
18
  * Stories. Envelope carries `sourceTickets[]`.
19
19
  *
20
+ * --amends 123 | #123 Amendment (delta) planning. Composes a DELTA
21
+ * envelope from the prior Story's body, its
22
+ * acceptance criteria, and its delivered file map
23
+ * instead of re-interrogating the repo from
24
+ * scratch (Story #4741). Envelope carries `amends`.
25
+ *
20
26
  * Flags:
21
27
  * --out <path> Write the envelope to <path> (parent dirs created).
22
28
  * `/plan` points this at `<plan-dir>/plan-context.json`,
@@ -83,6 +89,25 @@ export function parseTicketIds(raw) {
83
89
  return [...new Set(ids)];
84
90
  }
85
91
 
92
+ /**
93
+ * Parse a single `--amends` id, tolerating a leading `#` (`#123` or `123`).
94
+ *
95
+ * @param {string} raw
96
+ * @returns {number}
97
+ */
98
+ export function parseAmendsId(raw) {
99
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
100
+ throw new Error('--amends requires a single prior Story id.');
101
+ }
102
+ const id = Number(raw.trim().replace(/^#/, ''));
103
+ if (!Number.isInteger(id) || id <= 0) {
104
+ throw new Error(
105
+ `--amends expects a positive integer Story id; got ${JSON.stringify(raw)}`,
106
+ );
107
+ }
108
+ return id;
109
+ }
110
+
86
111
  /**
87
112
  * Build the envelope and write it to `stdout` as a single JSON line
88
113
  * (or pretty-printed with --pretty). Exported for tests.
@@ -96,6 +121,7 @@ export async function emitPlanContext({
96
121
  seedFileContent,
97
122
  seedText,
98
123
  ticketIds,
124
+ amendsId,
99
125
  provider,
100
126
  config,
101
127
  settings,
@@ -110,6 +136,7 @@ export async function emitPlanContext({
110
136
  seedFileContent,
111
137
  seedText,
112
138
  ticketIds,
139
+ amendsId,
113
140
  provider,
114
141
  config,
115
142
  settings,
@@ -139,14 +166,19 @@ export async function emitPlanContext({
139
166
  duplicates: (envelope.duplicates ?? []).length,
140
167
  // Advisory only (Story #4722): signals, no route — the planner owns
141
168
  // the trivial-vs-standard verdict and persist validates it by shape.
169
+ // The nested `deliverLightSuggestion` is the recorded plan-side routing
170
+ // handshake (Story #4741 AC-6) — advisory, never an automatic reroute.
142
171
  complexitySignals: envelope.complexitySignals
143
172
  ? {
144
173
  artifactCount: envelope.complexitySignals.artifactCount,
145
174
  riskHeuristicHits: envelope.complexitySignals.riskHeuristicHits,
146
175
  sensitivePathClasses:
147
176
  envelope.complexitySignals.sensitivePathClasses,
177
+ deliverLightSuggestion:
178
+ envelope.complexitySignals.deliverLightSuggestion ?? null,
148
179
  }
149
180
  : null,
181
+ amends: envelope.amends ? { id: envelope.amends.id } : null,
150
182
  };
151
183
  stdout.write(`${JSON.stringify(digest)}\n`);
152
184
  } else {
@@ -221,6 +253,7 @@ async function main() {
221
253
  seed: { type: 'string' },
222
254
  'seed-file': { type: 'string' },
223
255
  tickets: { type: 'string' },
256
+ amends: { type: 'string' },
224
257
  out: { type: 'string' },
225
258
  pretty: { type: 'boolean', default: false },
226
259
  },
@@ -234,16 +267,24 @@ async function main() {
234
267
  typeof seedFilePath === 'string' && seedFilePath.length > 0;
235
268
  const hasTickets =
236
269
  typeof values.tickets === 'string' && values.tickets.trim().length > 0;
237
- const entryForms = [hasSeed, hasSeedFile, hasTickets].filter(Boolean).length;
270
+ const hasAmends =
271
+ typeof values.amends === 'string' && values.amends.trim().length > 0;
272
+ const entryForms = [hasSeed, hasSeedFile, hasTickets, hasAmends].filter(
273
+ Boolean,
274
+ ).length;
238
275
  if (entryForms !== 1) {
239
276
  throw new Error(
240
- 'Pass exactly one of --seed "<text>", --seed-file <path>, or --tickets <ids>.',
277
+ 'Pass exactly one of --seed "<text>", --seed-file <path>, --tickets <ids>, or --amends <id>.',
241
278
  );
242
279
  }
243
280
 
244
281
  let mode;
245
282
  let ticketIds;
246
- if (hasTickets) {
283
+ let amendsId;
284
+ if (hasAmends) {
285
+ mode = 'amends';
286
+ amendsId = parseAmendsId(values.amends);
287
+ } else if (hasTickets) {
247
288
  mode = 'tickets';
248
289
  ticketIds = parseTicketIds(values.tickets);
249
290
  } else if (hasSeedFile) {
@@ -288,6 +329,7 @@ async function main() {
288
329
  seedFilePath: hasSeedFile ? seedFilePath : undefined,
289
330
  seedText: hasSeed ? seedText : undefined,
290
331
  ticketIds,
332
+ amendsId,
291
333
  provider,
292
334
  config,
293
335
  settings,
@@ -44,6 +44,15 @@
44
44
  * --no-close-superseded Keep the source tickets open (no comment, no
45
45
  * close) — for a genuinely partial supersede
46
46
  * --dry-run Assemble + validate without GitHub writes
47
+ * --chain-on-clean Plan-diet fast path (Story #4741): run the
48
+ * write-free dry-run first, and — only when it
49
+ * passes clean AND the plan resolves to the `lite`
50
+ * route — chain straight into the real persist in
51
+ * the SAME invocation, collapsing the two operator
52
+ * round-trips into one. A dry-run failure stops
53
+ * before any createIssue; a full-route plan keeps
54
+ * its review round-trip (the chain declines, no
55
+ * writes). Ignored when `--dry-run` is also set
47
56
  * --force-review Operator-forced review stop before persist lands
48
57
  * --allow-over-budget / --allow-large-fan-out
49
58
  *
@@ -111,6 +120,7 @@ const CLI_OPTIONS = {
111
120
  'close-superseded': { type: 'boolean', default: true },
112
121
  'no-close-superseded': { type: 'boolean', default: false },
113
122
  'dry-run': { type: 'boolean', default: false },
123
+ 'chain-on-clean': { type: 'boolean', default: false },
114
124
  'force-review': { type: 'boolean', default: false },
115
125
  'allow-over-budget': { type: 'boolean', default: false },
116
126
  'allow-large-fan-out': { type: 'boolean', default: false },
@@ -122,7 +132,7 @@ const USAGE =
122
132
  '[--plan-acceptance <file>] ' +
123
133
  '[--source-tickets <ids>] [--no-close-superseded] ' +
124
134
  '[--route-downgrade-reason <text>] ' +
125
- '[--dry-run] [--force-review] ' +
135
+ '[--dry-run] [--chain-on-clean] [--force-review] ' +
126
136
  '[--allow-over-budget] [--allow-large-fan-out]';
127
137
 
128
138
  async function readOptional(filePath, { required }) {
@@ -229,8 +239,11 @@ async function runPersistInvocation({
229
239
  provider,
230
240
  artifacts,
231
241
  metricsSince,
242
+ dryRun,
232
243
  }) {
233
244
  const paths = resolveInputPaths(values);
245
+ const effectiveDryRun =
246
+ typeof dryRun === 'boolean' ? dryRun : values['dry-run'] === true;
234
247
  const settings = {
235
248
  baseBranch: config.project?.baseBranch,
236
249
  paths: config.project?.paths,
@@ -241,7 +254,7 @@ async function runPersistInvocation({
241
254
  return recordPlanInvocation(
242
255
  {
243
256
  cli: 'plan-persist',
244
- mode: values['dry-run'] ? 'dry-run' : 'persist',
257
+ mode: effectiveDryRun ? 'dry-run' : 'persist',
245
258
  config,
246
259
  },
247
260
  () =>
@@ -252,12 +265,83 @@ async function runPersistInvocation({
252
265
  settings,
253
266
  opts: {
254
267
  ...buildPersistOptions(values, paths, artifacts.planContextEnvelope),
268
+ dryRun: effectiveDryRun,
269
+ skipCleanup: effectiveDryRun,
255
270
  metricsSince,
256
271
  },
257
272
  }),
258
273
  );
259
274
  }
260
275
 
276
+ /**
277
+ * Plan-diet fast path (Story #4741 AC-1/AC-3): chain the lite dry-run into the
278
+ * real persist in ONE operator invocation.
279
+ *
280
+ * Two passes over the **same** loaded artifacts:
281
+ *
282
+ * 1. A write-free dry-run. Every gate runs before any `createIssue` can
283
+ * happen, so a validation failure — which throws or returns reachability
284
+ * orphans — stops here, before a single issue exists (AC-3).
285
+ * 2. The real write, run **only** when the dry-run passed clean AND resolved
286
+ * to the `lite` route. Because it replays the identical artifacts, the
287
+ * persisted output is byte-identical to what the dry-run validated
288
+ * (AC-1). A full-route plan keeps its review round-trip: the chain
289
+ * declines and returns the dry-run result, mutating nothing.
290
+ *
291
+ * Exported for tests — this is where the round-trip collapse and its
292
+ * fail-closed guard live, so a regression here silently re-opens the second
293
+ * operator round-trip (or worse, persists a plan the dry-run never gated).
294
+ *
295
+ * @param {{ values: object, config: object, provider: object,
296
+ * artifacts: object, metricsSince: string }} args
297
+ * @returns {Promise<object>} the persist result, plus a `chain` receipt.
298
+ */
299
+ export async function runPersistChain({
300
+ values,
301
+ config,
302
+ provider,
303
+ artifacts,
304
+ metricsSince,
305
+ }) {
306
+ const dryResult = await runPersistInvocation({
307
+ values,
308
+ config,
309
+ provider,
310
+ artifacts,
311
+ metricsSince,
312
+ dryRun: true,
313
+ });
314
+
315
+ if (dryResult.route?.route !== 'lite') {
316
+ dryResult.chain = {
317
+ attempted: true,
318
+ persisted: false,
319
+ reason: 'route-not-lite',
320
+ };
321
+ Logger.info(
322
+ '[plan-persist] --chain-on-clean: dry-run clean but the plan did not ' +
323
+ 'resolve to the lite route — declining the auto-persist; run persist ' +
324
+ 'explicitly after review.',
325
+ );
326
+ return dryResult;
327
+ }
328
+
329
+ const persistResult = await runPersistInvocation({
330
+ values,
331
+ config,
332
+ provider,
333
+ artifacts,
334
+ metricsSince,
335
+ dryRun: false,
336
+ });
337
+ persistResult.chain = {
338
+ attempted: true,
339
+ persisted: true,
340
+ reason: 'lite-dry-run-clean',
341
+ };
342
+ return persistResult;
343
+ }
344
+
261
345
  /**
262
346
  * Attach the plan-metrics roll-up for **this** invocation.
263
347
  *
@@ -318,15 +402,28 @@ async function main() {
318
402
  const paths = resolveInputPaths(values);
319
403
  const artifacts = await loadArtifacts(paths);
320
404
 
405
+ // `--chain-on-clean` collapses the dry-run + persist operator round-trips
406
+ // (Story #4741). `--dry-run` always wins — an explicit dry-run never writes.
407
+ const useChain =
408
+ values['chain-on-clean'] === true && values['dry-run'] !== true;
409
+
321
410
  let result;
322
411
  try {
323
- result = await runPersistInvocation({
324
- values,
325
- config,
326
- provider,
327
- artifacts,
328
- metricsSince,
329
- });
412
+ result = useChain
413
+ ? await runPersistChain({
414
+ values,
415
+ config,
416
+ provider,
417
+ artifacts,
418
+ metricsSince,
419
+ })
420
+ : await runPersistInvocation({
421
+ values,
422
+ config,
423
+ provider,
424
+ artifacts,
425
+ metricsSince,
426
+ });
330
427
  } catch (err) {
331
428
  if (err?.code === 'PLAN_REACHABILITY_ORPHANS') {
332
429
  process.stdout.write(`${err.message}\n`);
@@ -0,0 +1,117 @@
1
+ ---
2
+ description:
3
+ Single-session delivery for genuinely small work. Judges a prompt's
4
+ predicted footprint, authors a receipt Story, then lands it through the same
5
+ single-story-init / single-story-close engine — every close gate unchanged.
6
+ ---
7
+
8
+ # /deliver-light "<prompt>" | --amends '#<id>' "<prompt>"
9
+
10
+ > **Thin entry point, not a second engine.** `/deliver-light` removes the
11
+ > `/plan` session for small work — nothing else. It runs a suitability gate,
12
+ > authors a minimal receipt Story, then hands off to the SAME scripts
13
+ > [`/deliver`](deliver.md) uses. Read
14
+ > [`helpers/deliver-digest.md`](helpers/deliver-digest.md) once first — the
15
+ > engine invariants, gates, and terminal-envelope contract below are its.
16
+
17
+ ## Role
18
+
19
+ For a genuinely trivial change — a one-file fix, a small addition, a small
20
+ amendment — the multi-session plan→deliver ceremony buys nothing the bare model
21
+ lacks except **gates and landing**. `/deliver-light` keeps exactly those: one
22
+ session straight to execution from an operator prompt, landing through the
23
+ unchanged close path. It never relaxes a close gate, never bypasses the PR to
24
+ `main`, and never lands over-scope work silently.
25
+
26
+ ## Four invariants (do not skip one)
27
+
28
+ 1. **Suitability gate.** The prompt's predicted footprint is judged by the
29
+ shared shape machinery (`deriveStoryShape` / `deriveChangeLevel`) **and** a
30
+ ledgered model verdict with a recorded reason. Both must agree on `lite`.
31
+ 2. **Over-scope stops — it never hard-fails.** An over-ceiling prompt STOPS and
32
+ asks the operator to escalate to `/plan` or proceed light. Under `--yes` it
33
+ fails closed to recommending `/plan`.
34
+ 3. **Diff-derived backstop.** After implementation the ACTUAL change set is
35
+ re-checked — the diff is the real scope signal — and an over-ceiling diff is
36
+ blocked rather than landed.
37
+ 4. **Minimal receipt Story.** A `type::story` is authored inline so `refs #`,
38
+ history, telemetry, and the `agent::executing → agent::done` state machine
39
+ all survive.
40
+
41
+ ## Procedure
42
+
43
+ 1. **Predict + gate.** Form the predicted footprint (new files, edited files,
44
+ acceptance count) and your ledgered verdict (a recorded reason for `lite`),
45
+ then run the gate:
46
+
47
+ ```bash
48
+ node .agents/scripts/deliver-light.js --prompt "<prompt>" \
49
+ --creates <csv> --refactors <csv> --acceptance <n> \
50
+ --route lite --reason "<why this is trivial>" [--amends '#<id>'] [--yes]
51
+ ```
52
+
53
+ Branch on `action` in the JSON envelope:
54
+ - **`proceed-light`** — the receipt Story is authored; read `storyId` and
55
+ `nextCommands`. Continue to step 2.
56
+ - **`ask-operator`** — predicted scope exceeds the light ceilings. STOP and
57
+ ask the operator to escalate to `/plan` or proceed light. Do not proceed
58
+ on your own.
59
+ - **`escalate-plan`** — over-scope under `--yes`: recommend `/plan` and stop.
60
+ `/deliver-light` never lands over-scope work.
61
+
62
+ `--amends '#<id>'` is the canonical light case — shape-checked identically; a
63
+ heavy amendment escalates to `/plan` like any other over-scope prompt.
64
+
65
+ 2. **Init (same engine).** From the main checkout, synchronously, with the
66
+ maximum Bash timeout:
67
+
68
+ ```bash
69
+ node .agents/scripts/single-story-init.js --story <storyId>
70
+ ```
71
+
72
+ Capture `workCwd`; `remoteVerified: false` → flip `agent::blocked` and stop.
73
+ This is [`/deliver`](deliver.md)'s worktree/branch/lease/label engine,
74
+ invoked, not reimplemented.
75
+
76
+ 3. **Implement + self-eval.** `cd` into `workCwd`, implement the change, run
77
+ `npm test` once in the worktree, then run the bounded acceptance self-eval
78
+ loop ([`helpers/deliver-story.md`](helpers/deliver-story.md) Step 1a). Commit
79
+ on `story-<id>` with `(refs #<storyId>)`.
80
+
81
+ 4. **Diff backstop.** Before close, re-check the ACTUAL diff:
82
+
83
+ ```bash
84
+ node .agents/scripts/deliver-light.js --backstop --story <storyId>
85
+ ```
86
+
87
+ Exit `3` (`blocked: true`) means the landed diff exceeds the light ceilings
88
+ (file count or a sensitive-path class). STOP, flip `agent::blocked`, and
89
+ escalate to `/plan` — do not land.
90
+
91
+ 5. **Close and land (same engine).** Exactly [`/deliver`](deliver.md)'s close:
92
+
93
+ ```bash
94
+ node .agents/scripts/single-story-close.js --story <storyId> --cwd <main-repo>
95
+ ```
96
+
97
+ Branch on the terminal envelope's `status` per
98
+ [`helpers/deliver-digest.md`](helpers/deliver-digest.md) § 5 — every close
99
+ gate runs byte-identical to the full path.
100
+
101
+ ## Constraints
102
+
103
+ - **Land or block — never a silent local build.** The close push is the only
104
+ sanctioned landing.
105
+ - **No parallel engine.** `/deliver-light` invokes `single-story-init.js` and
106
+ `single-story-close.js`; it never reimplements worktree, branch, PR, or merge
107
+ mechanics.
108
+ - **State only via `update-ticket-state.js`.** Drive every `agent::*`
109
+ transition through the script; report state, not process.
110
+
111
+ ## See also
112
+
113
+ - [`/deliver`](deliver.md) — the multi-Story / planned delivery entry point.
114
+ - [`helpers/deliver-story.md`](helpers/deliver-story.md) — the one Story
115
+ delivery engine both entry points share.
116
+ - [`helpers/deliver-digest.md`](helpers/deliver-digest.md) — engine invariants,
117
+ gates, and the terminal-envelope contract.
@@ -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).