mandrel 2.13.0 → 2.14.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.
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/story-deliver-terminal.schema.json",
|
|
4
4
|
"title": "story-deliver-terminal",
|
|
5
|
-
"description": "The single terminal envelope
|
|
5
|
+
"description": "The single terminal envelope a Story delivery invocation emits (Story #4543). Before this schema, the delivery tail had two divergent prose return contracts — one in .agents/workflows/helpers/deliver-story.md, a different one in .agents/agents/story-worker.md — and neither was validated by anything, so a caller could not tell a landed Story from a parked one without re-probing GitHub. This is the SSOT both now reference rather than restate. status is exactly one of landed | pending | blocked | failed | escalated; phase names where the run ended; tail carries per-step booleans so a partial-tail degradation is visible without failing an otherwise-landed merge; nextCommand names the single command that resumes or remediates the run, drawn from the same vocabulary deliver-recover.js prints. escalated (Story #4746) is the one status emitted BEFORE a Story exists — /deliver-light's suitability gate refusing an over-scope prompt — which is why storyId is null exactly there and non-null everywhere else.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": [
|
|
8
8
|
"kind",
|
|
@@ -14,16 +14,21 @@
|
|
|
14
14
|
],
|
|
15
15
|
"properties": {
|
|
16
16
|
"kind": { "type": "string", "const": "story-deliver-terminal" },
|
|
17
|
-
"storyId": {
|
|
17
|
+
"storyId": {
|
|
18
|
+
"type": ["integer", "null"],
|
|
19
|
+
"minimum": 1,
|
|
20
|
+
"description": "The Story this envelope reports on. null ONLY for status escalated, where the run refused before authoring a receipt Story — the allOf below pins that correspondence in both directions, so an escalated envelope cannot name a Story it did not create and a landed one cannot omit the Story it landed."
|
|
21
|
+
},
|
|
18
22
|
"status": {
|
|
19
23
|
"type": "string",
|
|
20
|
-
"description": "landed — the PR merged, the Story is agent::done, and the post-land tail was attempted. pending — a bounded wait expired with the PR still in flight; NO label was mutated and no merge.unlanded event was emitted, so the run is resumable via nextCommand. blocked — a classified hard block; the Story carries agent::blocked and blocked.blockClass names the class. failed — a phase crashed; phase names which one.",
|
|
21
|
-
"enum": ["landed", "pending", "blocked", "failed"]
|
|
24
|
+
"description": "landed — the PR merged, the Story is agent::done, and the post-land tail was attempted. pending — a bounded wait expired with the PR still in flight; NO label was mutated and no merge.unlanded event was emitted, so the run is resumable via nextCommand. blocked — a classified hard block; the Story carries agent::blocked and blocked.blockClass names the class. failed — a phase crashed; phase names which one. escalated — the /deliver-light suitability gate refused an over-scope prompt under --yes; nothing was created and the session ENDS here, nextCommand naming the /plan invocation that owns the work instead.",
|
|
25
|
+
"enum": ["landed", "pending", "blocked", "failed", "escalated"]
|
|
22
26
|
},
|
|
23
27
|
"phase": {
|
|
24
28
|
"type": "string",
|
|
25
|
-
"description": "The pipeline phase the run ended in. Mirrors the close pipeline's phase names so a terminal envelope is attributable to one step.",
|
|
29
|
+
"description": "The pipeline phase the run ended in. Mirrors the close pipeline's phase names so a terminal envelope is attributable to one step. suitability-gate precedes them all — it is the /deliver-light gate, the only phase that runs before a Story exists.",
|
|
26
30
|
"enum": [
|
|
31
|
+
"suitability-gate",
|
|
27
32
|
"init",
|
|
28
33
|
"wrong-tree-guard",
|
|
29
34
|
"close-validation",
|
|
@@ -130,9 +135,35 @@
|
|
|
130
135
|
},
|
|
131
136
|
"additionalProperties": false
|
|
132
137
|
},
|
|
138
|
+
"escalation": {
|
|
139
|
+
"type": ["object", "null"],
|
|
140
|
+
"description": "Present iff status === \"escalated\" (Story #4746). The suitability gate's decision was already correct before this block existed — what was missing was an outcome a session could not walk past. reasons carries the gate's own words; created records, per artifact, that the run started nothing.",
|
|
141
|
+
"required": ["reasons", "created"],
|
|
142
|
+
"properties": {
|
|
143
|
+
"reasons": {
|
|
144
|
+
"type": "array",
|
|
145
|
+
"minItems": 1,
|
|
146
|
+
"items": { "type": "string", "minLength": 1 },
|
|
147
|
+
"description": "The gate's reasons verbatim — the same strings the ask-operator path prints, so attended and unattended over-scope explain themselves identically."
|
|
148
|
+
},
|
|
149
|
+
"created": {
|
|
150
|
+
"type": "object",
|
|
151
|
+
"description": "Per-artifact proof that an escalated run left nothing half-started for a later run to trip over. Deliberately three const-false booleans rather than one aggregate flag or a bare omission: an omitted field reads as \"not checked\", and an aggregate is exactly the shape that let the post-land tail once report an outcome it never verified. Every value is pinned false by the schema, so an escalated envelope claiming it authored a receipt Story, cut a branch, or materialized a worktree cannot be built at all.",
|
|
152
|
+
"required": ["receiptStory", "storyBranch", "worktree"],
|
|
153
|
+
"properties": {
|
|
154
|
+
"receiptStory": { "const": false },
|
|
155
|
+
"storyBranch": { "const": false },
|
|
156
|
+
"worktree": { "const": false }
|
|
157
|
+
},
|
|
158
|
+
"additionalProperties": false
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
"additionalProperties": false
|
|
162
|
+
},
|
|
133
163
|
"nextCommand": {
|
|
134
164
|
"type": ["string", "null"],
|
|
135
|
-
"description": "The single command that advances this
|
|
165
|
+
"description": "The single command that advances this work from where it stopped, or null when status === \"landed\" and nothing remains. Shares its vocabulary with deliver-recover.js so recovery and normal resumption speak one language. For status escalated it is the /plan invocation the operator runs in a FRESH session — the one case where the command is a slash command rather than a script, because the work needs planning, not resumption.",
|
|
166
|
+
"minLength": 1
|
|
136
167
|
},
|
|
137
168
|
"elapsedSeconds": { "type": "number", "minimum": 0 },
|
|
138
169
|
"waitBudget": {
|
|
@@ -148,5 +179,28 @@
|
|
|
148
179
|
},
|
|
149
180
|
"timestamp": { "type": "string", "format": "date-time" }
|
|
150
181
|
},
|
|
182
|
+
"allOf": [
|
|
183
|
+
{
|
|
184
|
+
"description": "escalated is the pre-Story terminal, and the correspondence is pinned in BOTH directions. An escalated envelope MUST carry the escalation block, MUST have a null storyId (it created none), and MUST name the next command that owns the work — so a run cannot report escalation while pointing at a Story it started. Every other status MUST carry an integer storyId and MUST NOT carry an escalation block, so the new status cannot leak into the close path.",
|
|
185
|
+
"if": {
|
|
186
|
+
"properties": { "status": { "const": "escalated" } },
|
|
187
|
+
"required": ["status"]
|
|
188
|
+
},
|
|
189
|
+
"then": {
|
|
190
|
+
"required": ["escalation"],
|
|
191
|
+
"properties": {
|
|
192
|
+
"storyId": { "type": "null" },
|
|
193
|
+
"escalation": { "type": "object" },
|
|
194
|
+
"nextCommand": { "type": "string", "minLength": 1 }
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
"else": {
|
|
198
|
+
"properties": {
|
|
199
|
+
"storyId": { "type": "integer", "minimum": 1 },
|
|
200
|
+
"escalation": { "type": "null" }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
],
|
|
151
205
|
"additionalProperties": false
|
|
152
206
|
}
|
|
@@ -23,12 +23,25 @@
|
|
|
23
23
|
* - **gate** (default) — judge a prompt's predicted footprint. On
|
|
24
24
|
* `proceed-light` it authors the receipt Story (via the plan-persist
|
|
25
25
|
* `createStoryIssues` surface) and prints the init/close hand-off. On
|
|
26
|
-
* over-scope it prints `ask-operator` (attended) or `
|
|
27
|
-
* (`--yes`), never landing silently.
|
|
26
|
+
* over-scope it prints `ask-operator` (attended) or emits an `escalated`
|
|
27
|
+
* terminal envelope (`--yes`), never landing silently.
|
|
28
28
|
* - **backstop** (`--backstop --story <id>`) — re-check the ACTUAL diff of
|
|
29
29
|
* the Story branch after implementation; exit non-zero when it exceeds the
|
|
30
30
|
* light ceilings, so an over-scope diff is blocked rather than landed.
|
|
31
31
|
*
|
|
32
|
+
* ## Escalation is terminal, not advisory (Story #4746)
|
|
33
|
+
*
|
|
34
|
+
* Over-scope under `--yes` emits a schema-validated `story-deliver-terminal`
|
|
35
|
+
* envelope with status `escalated` and **ends the session**. Before that it was
|
|
36
|
+
* an ordinary gate envelope plus exit 2 — a warning a caller could walk past,
|
|
37
|
+
* and one mandrel-bench 2.13.0 light-arm run did exactly that: it read the
|
|
38
|
+
* escalation, invoked `/plan` in the same session, and delivered. In-session
|
|
39
|
+
* planning under-decomposed (ONE Story against the scenario's 3-5 contract,
|
|
40
|
+
* where a fresh `/plan` session on the identical seed authored four), so
|
|
41
|
+
* escalation silently produced the outcome the guard exists to prevent.
|
|
42
|
+
* {@link module:lib/orchestration/story-deliver-terminal.buildEscalationTerminal}
|
|
43
|
+
* carries the guarantees the schema then enforces.
|
|
44
|
+
*
|
|
32
45
|
* Usage:
|
|
33
46
|
* node .agents/scripts/deliver-light.js --prompt "<text>" \
|
|
34
47
|
* --creates path,path --acceptance 1 --route lite --reason "<why>"
|
|
@@ -36,7 +49,8 @@
|
|
|
36
49
|
* node .agents/scripts/deliver-light.js --backstop --story 4741
|
|
37
50
|
*
|
|
38
51
|
* Exit codes: 0 ok (proceed / clean backstop), 1 usage error, 2 the gate did
|
|
39
|
-
* not proceed light (ask-operator
|
|
52
|
+
* not proceed light (ask-operator, or an `escalated` terminal), 3 the diff
|
|
53
|
+
* backstop blocked.
|
|
40
54
|
*/
|
|
41
55
|
|
|
42
56
|
import { parseArgs } from 'node:util';
|
|
@@ -55,6 +69,11 @@ import {
|
|
|
55
69
|
assemblePlanStories,
|
|
56
70
|
createStoryIssues,
|
|
57
71
|
} from './lib/orchestration/plan-persist/story-ops.js';
|
|
72
|
+
import {
|
|
73
|
+
buildEscalationTerminal,
|
|
74
|
+
emitTerminalEnvelope,
|
|
75
|
+
exitCodeForTerminal,
|
|
76
|
+
} from './lib/orchestration/story-deliver-terminal.js';
|
|
58
77
|
import { createProvider } from './lib/provider-factory.js';
|
|
59
78
|
|
|
60
79
|
const HELP = `\
|
|
@@ -75,7 +94,8 @@ Gate options:
|
|
|
75
94
|
--route <r> Ledgered model verdict route: lite | full.
|
|
76
95
|
--reason <text> Recorded reason for a lite verdict (required for lite).
|
|
77
96
|
--amends <#id> Mark this as an amendment of an existing issue.
|
|
78
|
-
--yes Unattended: over-scope
|
|
97
|
+
--yes Unattended: over-scope emits an escalated terminal
|
|
98
|
+
envelope and ENDS the session (no prompt, no fallback).
|
|
79
99
|
|
|
80
100
|
Backstop options:
|
|
81
101
|
--backstop Re-check the ACTUAL diff after implementation.
|
|
@@ -291,10 +311,39 @@ async function runBackstopMode(values) {
|
|
|
291
311
|
/**
|
|
292
312
|
* Gate mode — judge the prompt and, on proceed, author the receipt Story.
|
|
293
313
|
*
|
|
314
|
+
* The three outcomes are deliberately asymmetric in what they emit:
|
|
315
|
+
*
|
|
316
|
+
* - **`escalate-plan`** returns a schema-validated `escalated` **terminal
|
|
317
|
+
* envelope** and stops (Story #4746). It is placed **first**, above every
|
|
318
|
+
* creation call site, so "nothing was started" is a property of the
|
|
319
|
+
* control flow rather than a claim the envelope makes about itself.
|
|
320
|
+
* - **`ask-operator`** is unchanged: the plain gate envelope and exit 2. It
|
|
321
|
+
* is not terminal — the operator has a choice to make, and manufacturing a
|
|
322
|
+
* terminal for it would end a session that is supposed to be waiting.
|
|
323
|
+
* - **`proceed-light`** authors the receipt Story and prints the hand-off.
|
|
324
|
+
*
|
|
325
|
+
* The injectable seams exist so the no-side-effect guarantee is testable
|
|
326
|
+
* without a network: a test asserts the escalate path never reaches them.
|
|
327
|
+
*
|
|
294
328
|
* @param {object} values Parsed CLI values.
|
|
329
|
+
* @param {{
|
|
330
|
+
* createProviderFn?: typeof createProvider,
|
|
331
|
+
* resolveConfigFn?: typeof resolveConfig,
|
|
332
|
+
* createReceiptFn?: typeof createLightReceipt,
|
|
333
|
+
* emitFn?: typeof emit,
|
|
334
|
+
* emitTerminalFn?: typeof emitTerminalEnvelope,
|
|
335
|
+
* }} [deps]
|
|
295
336
|
* @returns {Promise<number>}
|
|
296
337
|
*/
|
|
297
|
-
async function runGateMode(values) {
|
|
338
|
+
export async function runGateMode(values, deps = {}) {
|
|
339
|
+
const {
|
|
340
|
+
createProviderFn = createProvider,
|
|
341
|
+
resolveConfigFn = resolveConfig,
|
|
342
|
+
createReceiptFn = createLightReceipt,
|
|
343
|
+
emitFn = emit,
|
|
344
|
+
emitTerminalFn = emitTerminalEnvelope,
|
|
345
|
+
} = deps;
|
|
346
|
+
|
|
298
347
|
if (!values.prompt || String(values.prompt).trim() === '') {
|
|
299
348
|
process.stderr.write(HELP);
|
|
300
349
|
throw new Error('[deliver-light] --prompt <text> is required for the gate');
|
|
@@ -311,8 +360,20 @@ async function runGateMode(values) {
|
|
|
311
360
|
yes: values.yes === true,
|
|
312
361
|
});
|
|
313
362
|
|
|
363
|
+
if (gate.action === 'escalate-plan') {
|
|
364
|
+
const envelope = buildEscalationTerminal({
|
|
365
|
+
prompt: String(values.prompt),
|
|
366
|
+
reasons: gate.outcome.reasons,
|
|
367
|
+
});
|
|
368
|
+
emitTerminalFn(envelope);
|
|
369
|
+
Logger.warn(
|
|
370
|
+
`[deliver-light] ESCALATED to /plan — this session ENDS here; run ${envelope.nextCommand} in a FRESH session: ${gate.outcome.reasons.join('; ')}`,
|
|
371
|
+
);
|
|
372
|
+
return exitCodeForTerminal(envelope);
|
|
373
|
+
}
|
|
374
|
+
|
|
314
375
|
if (gate.action !== 'proceed-light') {
|
|
315
|
-
|
|
376
|
+
emitFn(
|
|
316
377
|
{ mode: 'gate', action: gate.action, outcome: gate.outcome },
|
|
317
378
|
values.pretty,
|
|
318
379
|
);
|
|
@@ -322,8 +383,8 @@ async function runGateMode(values) {
|
|
|
322
383
|
return EXIT_NOT_PROCEED;
|
|
323
384
|
}
|
|
324
385
|
|
|
325
|
-
const provider =
|
|
326
|
-
const receipt = await
|
|
386
|
+
const provider = createProviderFn(resolveConfigFn());
|
|
387
|
+
const receipt = await createReceiptFn({
|
|
327
388
|
provider,
|
|
328
389
|
prompt: String(values.prompt),
|
|
329
390
|
changedFiles: [
|
|
@@ -332,7 +393,7 @@ async function runGateMode(values) {
|
|
|
332
393
|
],
|
|
333
394
|
amends: values.amends ?? null,
|
|
334
395
|
});
|
|
335
|
-
|
|
396
|
+
emitFn(
|
|
336
397
|
{
|
|
337
398
|
mode: 'gate',
|
|
338
399
|
action: 'proceed-light',
|
|
@@ -61,6 +61,15 @@ export const TERMINAL_EXIT_CODES = Object.freeze({
|
|
|
61
61
|
pending: 3,
|
|
62
62
|
blocked: 1,
|
|
63
63
|
failed: 1,
|
|
64
|
+
/**
|
|
65
|
+
* `escalated` reuses **2**, the code `/deliver-light` already documents for
|
|
66
|
+
* "the gate did not proceed light" — the escalation is that outcome made
|
|
67
|
+
* terminal, not a new one, so giving it a fresh code would fork a vocabulary
|
|
68
|
+
* callers already branch on. It stays distinct from `landed` (nothing was
|
|
69
|
+
* delivered) and from `blocked`/`failed` (nothing is wrong — the work simply
|
|
70
|
+
* belongs to `/plan`).
|
|
71
|
+
*/
|
|
72
|
+
escalated: 2,
|
|
64
73
|
});
|
|
65
74
|
|
|
66
75
|
export const TERMINAL_STATUSES = Object.freeze([
|
|
@@ -68,13 +77,38 @@ export const TERMINAL_STATUSES = Object.freeze([
|
|
|
68
77
|
'pending',
|
|
69
78
|
'blocked',
|
|
70
79
|
'failed',
|
|
80
|
+
'escalated',
|
|
71
81
|
]);
|
|
72
82
|
|
|
83
|
+
/** Cap on the prompt echoed into an escalation's `/plan` next command. */
|
|
84
|
+
const PLAN_PROMPT_MAX = 200;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render an operator prompt safe to sit inside the double quotes of the
|
|
88
|
+
* `/plan "<prompt>"` next command: collapse newlines (a next command is one
|
|
89
|
+
* line by contract), escape backslashes and double quotes so the quoting
|
|
90
|
+
* cannot be broken out of, and cap the length so a long prompt does not turn
|
|
91
|
+
* the envelope into a transcript. Total — a non-string yields the empty
|
|
92
|
+
* string, which the escalation builder rejects rather than emitting.
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} prompt
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function quoteForPlan(prompt) {
|
|
98
|
+
const text =
|
|
99
|
+
typeof prompt === 'string' ? prompt.replace(/\s+/g, ' ').trim() : '';
|
|
100
|
+
const capped =
|
|
101
|
+
text.length > PLAN_PROMPT_MAX
|
|
102
|
+
? `${text.slice(0, PLAN_PROMPT_MAX - 1).trimEnd()}…`
|
|
103
|
+
: text;
|
|
104
|
+
return capped.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
/**
|
|
74
108
|
* The shared next-command vocabulary. Every producer of a "what now?"
|
|
75
|
-
* answer — the `pending` terminal envelope
|
|
76
|
-
* builds its command from here, so the
|
|
77
|
-
* naming different commands for the same observed state.
|
|
109
|
+
* answer — the `pending` terminal envelope, an `escalated` one, and
|
|
110
|
+
* `deliver-recover.js` — builds its command from here, so the surfaces never
|
|
111
|
+
* drift into naming different commands for the same observed state.
|
|
78
112
|
*/
|
|
79
113
|
export const NEXT_COMMANDS = Object.freeze({
|
|
80
114
|
/**
|
|
@@ -104,6 +138,18 @@ export const NEXT_COMMANDS = Object.freeze({
|
|
|
104
138
|
/** Probe a stranded Story and print its single next command. */
|
|
105
139
|
recover: (storyId) =>
|
|
106
140
|
`node .agents/scripts/deliver-recover.js --story ${storyId}`,
|
|
141
|
+
/**
|
|
142
|
+
* Hand over-scope work to `/plan` — the next command of an `escalated`
|
|
143
|
+
* terminal (Story #4746).
|
|
144
|
+
*
|
|
145
|
+
* The only entry here that is a slash command rather than a script, and
|
|
146
|
+
* deliberately so: the other entries resume a Story that exists, while this
|
|
147
|
+
* one names work that has no Story yet and needs planning before it can have
|
|
148
|
+
* one. It is quoted for a shell but addressed to a **fresh session** — see
|
|
149
|
+
* the workflow's escalation section for why running it in the escalating
|
|
150
|
+
* session is forbidden.
|
|
151
|
+
*/
|
|
152
|
+
escalateToPlan: (prompt) => `/plan "${quoteForPlan(prompt)}"`,
|
|
107
153
|
});
|
|
108
154
|
|
|
109
155
|
/** @type {Function|null} */
|
|
@@ -165,8 +211,9 @@ function compact(obj) {
|
|
|
165
211
|
* this replaces.
|
|
166
212
|
*
|
|
167
213
|
* @param {object} args
|
|
168
|
-
* @param {number} args.storyId
|
|
169
|
-
*
|
|
214
|
+
* @param {number|null} args.storyId `null` only for an `escalated` terminal,
|
|
215
|
+
* which by construction never authored a Story.
|
|
216
|
+
* @param {'landed'|'pending'|'blocked'|'failed'|'escalated'} args.status
|
|
170
217
|
* @param {string} args.phase
|
|
171
218
|
* @param {string} [args.storyBranch]
|
|
172
219
|
* @param {string} [args.baseBranch]
|
|
@@ -175,6 +222,7 @@ function compact(obj) {
|
|
|
175
222
|
* @param {object|null} [args.tail]
|
|
176
223
|
* @param {object|null} [args.blocked]
|
|
177
224
|
* @param {object|null} [args.failure]
|
|
225
|
+
* @param {object|null} [args.escalation]
|
|
178
226
|
* @param {string|null} [args.nextCommand]
|
|
179
227
|
* @param {number} args.elapsedSeconds
|
|
180
228
|
* @param {object|null} [args.waitBudget]
|
|
@@ -192,6 +240,7 @@ export function buildTerminalEnvelope({
|
|
|
192
240
|
tail,
|
|
193
241
|
blocked,
|
|
194
242
|
failure,
|
|
243
|
+
escalation,
|
|
195
244
|
nextCommand,
|
|
196
245
|
elapsedSeconds = 0,
|
|
197
246
|
waitBudget,
|
|
@@ -199,7 +248,10 @@ export function buildTerminalEnvelope({
|
|
|
199
248
|
}) {
|
|
200
249
|
const envelope = compact({
|
|
201
250
|
kind: TERMINAL_ENVELOPE_KIND,
|
|
202
|
-
|
|
251
|
+
// `Number(null)` is 0, which would quietly satisfy nothing and confuse
|
|
252
|
+
// everything — a nullish storyId stays null and lets the schema decide
|
|
253
|
+
// whether this status is allowed to omit one.
|
|
254
|
+
storyId: storyId === null || storyId === undefined ? null : Number(storyId),
|
|
203
255
|
status,
|
|
204
256
|
phase,
|
|
205
257
|
storyBranch: storyBranch ?? null,
|
|
@@ -209,6 +261,7 @@ export function buildTerminalEnvelope({
|
|
|
209
261
|
tail: tail ?? null,
|
|
210
262
|
blocked: blocked ?? null,
|
|
211
263
|
failure: failure ?? null,
|
|
264
|
+
escalation: escalation ?? null,
|
|
212
265
|
nextCommand: nextCommand ?? null,
|
|
213
266
|
elapsedSeconds: Math.max(0, Number(elapsedSeconds) || 0),
|
|
214
267
|
waitBudget: waitBudget ?? null,
|
|
@@ -225,6 +278,69 @@ export function buildTerminalEnvelope({
|
|
|
225
278
|
return envelope;
|
|
226
279
|
}
|
|
227
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Build the `escalated` terminal — the one envelope emitted before a Story
|
|
283
|
+
* exists (Story #4746).
|
|
284
|
+
*
|
|
285
|
+
* `/deliver-light`'s suitability gate already decided correctly when it
|
|
286
|
+
* overrode a `lite` self-verdict on shape; what it lacked was an outcome a
|
|
287
|
+
* session could not walk past. A mandrel-bench 2.13.0 light-arm run did
|
|
288
|
+
* exactly that — it read the gate's `escalate-plan`, then invoked `/plan`
|
|
289
|
+
* in the same session and delivered. The continuation was not harmless:
|
|
290
|
+
* planning inside a session already framed as small work authored ONE Story
|
|
291
|
+
* against the scenario's 3-5 contract, where a fresh `/plan` session on the
|
|
292
|
+
* identical seed authored four. Escalation silently produced the very
|
|
293
|
+
* under-decomposition the guard exists to prevent.
|
|
294
|
+
*
|
|
295
|
+
* So the outcome is a validated envelope with its own exit code, naming the
|
|
296
|
+
* `/plan` command that owns the work, and asserting per artifact that nothing
|
|
297
|
+
* was started. Every guarantee here is enforced by the schema rather than by
|
|
298
|
+
* prose: `storyId` must be null, `escalation.created.*` are pinned `false`.
|
|
299
|
+
*
|
|
300
|
+
* @param {{
|
|
301
|
+
* prompt: string,
|
|
302
|
+
* reasons?: string[],
|
|
303
|
+
* elapsedSeconds?: number,
|
|
304
|
+
* timestamp?: string,
|
|
305
|
+
* }} args
|
|
306
|
+
* @returns {object} The validated `escalated` envelope.
|
|
307
|
+
*/
|
|
308
|
+
export function buildEscalationTerminal({
|
|
309
|
+
prompt,
|
|
310
|
+
reasons,
|
|
311
|
+
elapsedSeconds = 0,
|
|
312
|
+
timestamp,
|
|
313
|
+
}) {
|
|
314
|
+
const quoted = quoteForPlan(prompt);
|
|
315
|
+
if (quoted === '') {
|
|
316
|
+
// An escalation whose next command is `/plan ""` hands the operator
|
|
317
|
+
// nothing — the same walk-past-able non-outcome in envelope clothing.
|
|
318
|
+
throw new TypeError(
|
|
319
|
+
'buildEscalationTerminal: a non-empty prompt is required — the escalated terminal exists to name the /plan invocation that owns the work',
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const recorded = (Array.isArray(reasons) ? reasons : []).filter(
|
|
323
|
+
(r) => typeof r === 'string' && r.trim() !== '',
|
|
324
|
+
);
|
|
325
|
+
return buildTerminalEnvelope({
|
|
326
|
+
storyId: null,
|
|
327
|
+
status: 'escalated',
|
|
328
|
+
phase: 'suitability-gate',
|
|
329
|
+
escalation: {
|
|
330
|
+
reasons:
|
|
331
|
+
recorded.length > 0
|
|
332
|
+
? recorded
|
|
333
|
+
: ['predicted scope exceeds the light ceilings — escalate to /plan'],
|
|
334
|
+
// Not computed from anything: the escalation path returns before the
|
|
335
|
+
// receipt/init call sites, so these are the assertion that it did.
|
|
336
|
+
created: { receiptStory: false, storyBranch: false, worktree: false },
|
|
337
|
+
},
|
|
338
|
+
nextCommand: NEXT_COMMANDS.escalateToPlan(prompt),
|
|
339
|
+
elapsedSeconds,
|
|
340
|
+
...(timestamp === undefined ? {} : { timestamp }),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
228
344
|
/**
|
|
229
345
|
* Resolve the process exit code for a terminal envelope.
|
|
230
346
|
*
|
|
@@ -30,7 +30,8 @@ unchanged close path. It never relaxes a close gate, never bypasses the PR to
|
|
|
30
30
|
ledgered model verdict with a recorded reason. Both must agree on `lite`.
|
|
31
31
|
2. **Over-scope stops — it never hard-fails.** An over-ceiling prompt STOPS and
|
|
32
32
|
asks the operator to escalate to `/plan` or proceed light. Under `--yes` it
|
|
33
|
-
fails closed to
|
|
33
|
+
fails closed to an **`escalated` terminal envelope** that ends the session
|
|
34
|
+
(§ Escalation is terminal).
|
|
34
35
|
3. **Diff-derived backstop.** After implementation the ACTUAL change set is
|
|
35
36
|
re-checked — the diff is the real scope signal — and an over-ceiling diff is
|
|
36
37
|
blocked rather than landed.
|
|
@@ -55,9 +56,10 @@ unchanged close path. It never relaxes a close gate, never bypasses the PR to
|
|
|
55
56
|
`nextCommands`. Continue to step 2.
|
|
56
57
|
- **`ask-operator`** — predicted scope exceeds the light ceilings. STOP and
|
|
57
58
|
ask the operator to escalate to `/plan` or proceed light. Do not proceed
|
|
58
|
-
on your own.
|
|
59
|
-
-
|
|
60
|
-
|
|
59
|
+
on your own. This is a **question, not a terminal** — wait for the answer.
|
|
60
|
+
- **over-scope under `--yes`** — no `action` to branch on: the gate emits an
|
|
61
|
+
**`escalated` terminal envelope** instead (exit 2). § Escalation is
|
|
62
|
+
terminal governs; you are finished.
|
|
61
63
|
|
|
62
64
|
`--amends '#<id>'` is the canonical light case — shape-checked identically; a
|
|
63
65
|
heavy amendment escalates to `/plan` like any other over-scope prompt.
|
|
@@ -98,10 +100,39 @@ unchanged close path. It never relaxes a close gate, never bypasses the PR to
|
|
|
98
100
|
[`helpers/deliver-digest.md`](helpers/deliver-digest.md) § 5 — every close
|
|
99
101
|
gate runs byte-identical to the full path.
|
|
100
102
|
|
|
103
|
+
## Escalation is terminal {#escalation-is-terminal}
|
|
104
|
+
|
|
105
|
+
Over-scope under `--yes` emits a schema-validated `story-deliver-terminal`
|
|
106
|
+
envelope with **`status: "escalated"`**, `storyId: null`, and a `nextCommand`
|
|
107
|
+
naming the `/plan` invocation that owns the work.
|
|
108
|
+
|
|
109
|
+
**That envelope IS this session's terminal output.** Relay it and stop. There is
|
|
110
|
+
no remaining step, no degraded fallback, and no smaller version of the work to
|
|
111
|
+
attempt.
|
|
112
|
+
|
|
113
|
+
**Invoking `/plan` in this same session is forbidden.** Hand the operator the
|
|
114
|
+
`nextCommand`; `/plan` runs in a **fresh** session.
|
|
115
|
+
|
|
116
|
+
This is not style — it is the empirical finding that motivated the envelope.
|
|
117
|
+
A mandrel-bench 2.13.0 light-arm run read the escalation and continued anyway:
|
|
118
|
+
it invoked `/plan` in-session and delivered. The in-session plan authored **one**
|
|
119
|
+
Story against the scenario's 3–5 contract, where a fresh `/plan` session on the
|
|
120
|
+
identical seed authored **four**. Planning inside a session already framed as
|
|
121
|
+
small work under-decomposes, so walking past the escalation silently produced
|
|
122
|
+
the very outcome the guard exists to prevent. The gate's decision was right both
|
|
123
|
+
times; only the outcome's finality was missing.
|
|
124
|
+
|
|
125
|
+
Nothing is left half-started: an escalated run creates **no receipt Story, no
|
|
126
|
+
`story-<id>` branch, and no worktree** — the escalation path returns before
|
|
127
|
+
every creation call site, and `escalation.created` records all three as `false`
|
|
128
|
+
in a shape the schema pins, so a later run finds nothing to trip over.
|
|
129
|
+
|
|
101
130
|
## Constraints
|
|
102
131
|
|
|
103
|
-
- **Land or
|
|
104
|
-
sanctioned landing
|
|
132
|
+
- **Land, block, or escalate — never a silent local build.** The close push is
|
|
133
|
+
the only sanctioned landing; an `escalated` terminal is the only sanctioned
|
|
134
|
+
ending that delivers nothing, and it ends the session
|
|
135
|
+
(§ Escalation is terminal).
|
|
105
136
|
- **No parallel engine.** `/deliver-light` invokes `single-story-init.js` and
|
|
106
137
|
`single-story-close.js`; it never reimplements worktree, branch, PR, or merge
|
|
107
138
|
mechanics.
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.14.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.13.0...mandrel-v2.14.0) (2026-07-24)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* **deliver-light:** make escalate-plan a terminal outcome (refs [#4746](https://github.com/dsj1984/mandrel/issues/4746)) ([#4747](https://github.com/dsj1984/mandrel/issues/4747)) ([53f3151](https://github.com/dsj1984/mandrel/commit/53f3151cff5a22e8ce938ec286cc2898985eb892))
|
|
11
|
+
|
|
5
12
|
## [2.13.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.12.0...mandrel-v2.13.0) (2026-07-24)
|
|
6
13
|
|
|
7
14
|
|
package/package.json
CHANGED