mandrel 2.12.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.
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +60 -6
- package/.agents/scripts/deliver-light.js +446 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +7 -4
- package/.agents/scripts/lib/orchestration/light-suitability.js +439 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +256 -15
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +122 -6
- package/.agents/scripts/plan-context.js +45 -3
- package/.agents/scripts/plan-persist.js +106 -9
- package/.agents/workflows/deliver-light.md +148 -0
- package/.agents/workflows/plan.md +66 -84
- package/docs/CHANGELOG.md +15 -0
- package/package.json +1 -1
|
@@ -32,7 +32,7 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
|
|
|
32
32
|
fails when it drifts from the on-disk workflow set. To change a command’s
|
|
33
33
|
description, edit the workflow file’s front-matter and regenerate.
|
|
34
34
|
|
|
35
|
-
## Commands (
|
|
35
|
+
## Commands (25)
|
|
36
36
|
|
|
37
37
|
| Command | Description |
|
|
38
38
|
| --- | --- |
|
|
@@ -53,6 +53,7 @@ description, edit the workflow file’s front-matter and regenerate.
|
|
|
53
53
|
| `/audit-to-stories` | Convert findings produced by the audit-\* workflows into actionable GitHub Stories. Reads temp/audits/audit-\*-results.md, groups findings cross-audit, deduplicates against existing Issues by fingerprint, and either chains into /plan --seed-file or opens standalone Stories. |
|
|
54
54
|
| `/audit-ux-ui` | Audit UX/UI consistency and design system adherence |
|
|
55
55
|
| `/deliver` | Unified delivery entry point. Takes a list of Story ids, resolves their dependency graph from live state, and delivers each via the single deliver-story engine — story-<id> → PR → main. |
|
|
56
|
+
| `/deliver-light` | Single-session delivery for genuinely small work. Judges a prompt's predicted footprint, authors a receipt Story, then lands it through the same single-story-init / single-story-close engine — every close gate unchanged. |
|
|
56
57
|
| `/git-cleanup` | Tidy the local checkout in four phases: fast-forward `main`, prune stale remote-tracking refs, sweep merged branches (squash-aware), and triage `git stash` entries — each step gated by operator confirmation. |
|
|
57
58
|
| `/git-deliver` | Single ad-hoc delivery command for working-tree changes. Detects the git setup and escalates to the right terminal step — commit only, commit + push, or commit + push + open a PR with native auto-merge — picking the default from observable state and letting flags pin any level explicitly. Replaces the retired git-commit-all, git-push, and git-pr-all trio. |
|
|
58
59
|
| `/mandrel-update` | npm-era upgrade wraparound for a Mandrel consumer. Runs `npx mandrel update` (resolve newest published version → install → re-materialize `.agents/` → migrate → doctor → surface changelog) as the single mechanical step, then walks the operator through the judgment wraparound the CLI deliberately leaves unowned: reconcile `.agentrc.json`, install the Epic #1386 quality-gate surface, refresh the harness permission allowlist, reconcile the consumer's `AGENTS.md` / runbooks against the surfaced changelog, and stage + commit the staged lockfile bump. |
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* deliver-light.js — the `/deliver-light` entry point (Story #4740).
|
|
5
|
+
*
|
|
6
|
+
* A **thin entry point, not a second delivery engine.** It runs the
|
|
7
|
+
* suitability gate, authors a minimal receipt `type::story`, and then hands off
|
|
8
|
+
* to the SAME engine scripts `/deliver` uses:
|
|
9
|
+
*
|
|
10
|
+
* suitability gate → inline receipt Story → single-story-init.js
|
|
11
|
+
* → (agent implements + self-evals) → diff backstop
|
|
12
|
+
* → single-story-close.js (close-and-land, every gate byte-identical)
|
|
13
|
+
*
|
|
14
|
+
* Worktree, branch, lease, PR, and merge mechanics are **invoked, never
|
|
15
|
+
* reimplemented** — this file contains no parallel init/close logic
|
|
16
|
+
* ({@link buildNextCommands} references the engine scripts by name). The
|
|
17
|
+
* reusable decision core lives in
|
|
18
|
+
* {@link module:lib/orchestration/light-suitability}; this module is the CLI
|
|
19
|
+
* shell plus the receipt-authoring and diff-backstop wiring.
|
|
20
|
+
*
|
|
21
|
+
* Two modes:
|
|
22
|
+
*
|
|
23
|
+
* - **gate** (default) — judge a prompt's predicted footprint. On
|
|
24
|
+
* `proceed-light` it authors the receipt Story (via the plan-persist
|
|
25
|
+
* `createStoryIssues` surface) and prints the init/close hand-off. On
|
|
26
|
+
* over-scope it prints `ask-operator` (attended) or emits an `escalated`
|
|
27
|
+
* terminal envelope (`--yes`), never landing silently.
|
|
28
|
+
* - **backstop** (`--backstop --story <id>`) — re-check the ACTUAL diff of
|
|
29
|
+
* the Story branch after implementation; exit non-zero when it exceeds the
|
|
30
|
+
* light ceilings, so an over-scope diff is blocked rather than landed.
|
|
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
|
+
*
|
|
45
|
+
* Usage:
|
|
46
|
+
* node .agents/scripts/deliver-light.js --prompt "<text>" \
|
|
47
|
+
* --creates path,path --acceptance 1 --route lite --reason "<why>"
|
|
48
|
+
* node .agents/scripts/deliver-light.js --prompt "<text>" --amends '#123' --route lite --reason "<why>"
|
|
49
|
+
* node .agents/scripts/deliver-light.js --backstop --story 4741
|
|
50
|
+
*
|
|
51
|
+
* Exit codes: 0 ok (proceed / clean backstop), 1 usage error, 2 the gate did
|
|
52
|
+
* not proceed light (ask-operator, or an `escalated` terminal), 3 the diff
|
|
53
|
+
* backstop blocked.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { parseArgs } from 'node:util';
|
|
57
|
+
|
|
58
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
59
|
+
import { resolveConfig } from './lib/config-resolver.js';
|
|
60
|
+
import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
|
|
61
|
+
import { computeChangeSet } from './lib/orchestration/change-set.js';
|
|
62
|
+
import {
|
|
63
|
+
buildReceiptStoryTicket,
|
|
64
|
+
checkLightDiffBackstop,
|
|
65
|
+
deriveLightSuitability,
|
|
66
|
+
resolveLightGateOutcome,
|
|
67
|
+
} from './lib/orchestration/light-suitability.js';
|
|
68
|
+
import {
|
|
69
|
+
assemblePlanStories,
|
|
70
|
+
createStoryIssues,
|
|
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';
|
|
77
|
+
import { createProvider } from './lib/provider-factory.js';
|
|
78
|
+
|
|
79
|
+
const HELP = `\
|
|
80
|
+
Usage:
|
|
81
|
+
deliver-light.js --prompt <text> [--creates csv] [--refactors csv]
|
|
82
|
+
[--acceptance n] [--route lite|full] [--reason <text>]
|
|
83
|
+
[--amends '#id'] [--yes]
|
|
84
|
+
deliver-light.js --backstop --story <id>
|
|
85
|
+
|
|
86
|
+
The thin /deliver-light entry point: suitability gate → inline receipt Story →
|
|
87
|
+
the same single-story-init.js / single-story-close.js engine /deliver uses.
|
|
88
|
+
|
|
89
|
+
Gate options:
|
|
90
|
+
--prompt <text> Operator prompt describing the change. Required for the gate.
|
|
91
|
+
--creates <csv> Predicted NEW file paths (comma-separated).
|
|
92
|
+
--refactors <csv> Predicted edited/existing file paths (comma-separated).
|
|
93
|
+
--acceptance <n> Predicted acceptance-criteria count (default 1).
|
|
94
|
+
--route <r> Ledgered model verdict route: lite | full.
|
|
95
|
+
--reason <text> Recorded reason for a lite verdict (required for lite).
|
|
96
|
+
--amends <#id> Mark this as an amendment of an existing issue.
|
|
97
|
+
--yes Unattended: over-scope emits an escalated terminal
|
|
98
|
+
envelope and ENDS the session (no prompt, no fallback).
|
|
99
|
+
|
|
100
|
+
Backstop options:
|
|
101
|
+
--backstop Re-check the ACTUAL diff after implementation.
|
|
102
|
+
--story <id> Story issue number whose story-<id> branch to diff.
|
|
103
|
+
|
|
104
|
+
--pretty Pretty-print the JSON envelope.
|
|
105
|
+
--help Show this help.
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
/** Exit code when the gate did not resolve to proceed-light. */
|
|
109
|
+
const EXIT_NOT_PROCEED = 2;
|
|
110
|
+
/** Exit code when the diff backstop blocked the land. */
|
|
111
|
+
const EXIT_BACKSTOP_BLOCKED = 3;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Split a comma-separated path list into trimmed, non-empty entries.
|
|
115
|
+
*
|
|
116
|
+
* @param {string|undefined} csv
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
export function parseCsvPaths(csv) {
|
|
120
|
+
if (typeof csv !== 'string' || csv.trim() === '') return [];
|
|
121
|
+
return csv
|
|
122
|
+
.split(',')
|
|
123
|
+
.map((s) => s.trim())
|
|
124
|
+
.filter((s) => s !== '');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Assemble the predicted `changes[]` footprint from the declared creates /
|
|
129
|
+
* refactors lists — the input {@link deriveLightSuitability} shape-checks.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ creates?: string[], refactors?: string[] }} args
|
|
132
|
+
* @returns {Array<{ path: string, assumption: string }>}
|
|
133
|
+
*/
|
|
134
|
+
export function buildPredictedChanges({ creates = [], refactors = [] } = {}) {
|
|
135
|
+
return [
|
|
136
|
+
...creates.map((path) => ({ path, assumption: 'creates' })),
|
|
137
|
+
...refactors.map((path) => ({ path, assumption: 'refactors-existing' })),
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Synthesize a predicted-acceptance array of the requested length — the shape
|
|
143
|
+
* gate reads the count, not the text, so placeholder strings suffice. A count
|
|
144
|
+
* below 1 yields a single-item array (a Story with no contract cannot be judged
|
|
145
|
+
* trivial, and the shape derivation rejects a zero-length acceptance anyway).
|
|
146
|
+
*
|
|
147
|
+
* @param {unknown} count
|
|
148
|
+
* @returns {string[]}
|
|
149
|
+
*/
|
|
150
|
+
export function synthesizeAcceptance(count) {
|
|
151
|
+
const n =
|
|
152
|
+
typeof count === 'number' && Number.isFinite(count) && count >= 1
|
|
153
|
+
? Math.floor(count)
|
|
154
|
+
: 1;
|
|
155
|
+
return Array.from({ length: n }, (_v, i) => `AC-${i + 1}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run the suitability gate purely — no I/O. Returns the outcome envelope the
|
|
160
|
+
* CLI serializes. The prompt text and `--amends` target are deliberately **not**
|
|
161
|
+
* inputs: routing is shape-checked identically whether or not the change is an
|
|
162
|
+
* amendment (Story #4740 R3), and the prompt's text carries no routing signal —
|
|
163
|
+
* the predicted footprint does. Both flow into the receipt Story instead.
|
|
164
|
+
*
|
|
165
|
+
* @param {{
|
|
166
|
+
* creates?: string[],
|
|
167
|
+
* refactors?: string[],
|
|
168
|
+
* acceptance?: number,
|
|
169
|
+
* route?: string,
|
|
170
|
+
* reason?: string,
|
|
171
|
+
* yes?: boolean,
|
|
172
|
+
* injectedRules?: object,
|
|
173
|
+
* }} args
|
|
174
|
+
* @returns {{ action: string, suitability: object, outcome: object }}
|
|
175
|
+
*/
|
|
176
|
+
export function runLightGate({
|
|
177
|
+
creates = [],
|
|
178
|
+
refactors = [],
|
|
179
|
+
acceptance,
|
|
180
|
+
route,
|
|
181
|
+
reason,
|
|
182
|
+
yes = false,
|
|
183
|
+
injectedRules,
|
|
184
|
+
} = {}) {
|
|
185
|
+
const predictedChanges = buildPredictedChanges({ creates, refactors });
|
|
186
|
+
const suitability = deriveLightSuitability({
|
|
187
|
+
predictedChanges,
|
|
188
|
+
predictedAcceptance: synthesizeAcceptance(acceptance),
|
|
189
|
+
verdict: { route, reason },
|
|
190
|
+
injectedRules,
|
|
191
|
+
});
|
|
192
|
+
const outcome = resolveLightGateOutcome({ suitability, yes });
|
|
193
|
+
return { action: outcome.action, suitability, outcome };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Author the receipt Story via the plan-persist creation surface (reused, not
|
|
198
|
+
* reimplemented). Injectable seams keep it unit-testable without a network.
|
|
199
|
+
*
|
|
200
|
+
* @param {{
|
|
201
|
+
* provider: object,
|
|
202
|
+
* prompt: string,
|
|
203
|
+
* changedFiles?: string[],
|
|
204
|
+
* amends?: string|number|null,
|
|
205
|
+
* assembleFn?: typeof assemblePlanStories,
|
|
206
|
+
* createFn?: typeof createStoryIssues,
|
|
207
|
+
* }} args
|
|
208
|
+
* @returns {Promise<{ storyId: number, url: string|undefined, title: string }>}
|
|
209
|
+
*/
|
|
210
|
+
export async function createLightReceipt({
|
|
211
|
+
provider,
|
|
212
|
+
prompt,
|
|
213
|
+
changedFiles = [],
|
|
214
|
+
amends = null,
|
|
215
|
+
assembleFn = assemblePlanStories,
|
|
216
|
+
createFn = createStoryIssues,
|
|
217
|
+
} = {}) {
|
|
218
|
+
const ticket = buildReceiptStoryTicket({ prompt, changedFiles, amends });
|
|
219
|
+
const { stories } = assembleFn([ticket]);
|
|
220
|
+
const { created } = await createFn({ provider, stories });
|
|
221
|
+
const receipt = created[0];
|
|
222
|
+
if (!receipt || !Number.isInteger(receipt.id)) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
'[deliver-light] receipt Story creation did not return a numeric id',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return { storyId: receipt.id, url: receipt.url, title: receipt.title };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The engine hand-off — the SAME scripts `/deliver` uses. Named here as
|
|
232
|
+
* commands, never reimplemented: this is the whole of deliver-light's
|
|
233
|
+
* relationship to worktree/branch/lease/PR/merge mechanics.
|
|
234
|
+
*
|
|
235
|
+
* @param {number} storyId
|
|
236
|
+
* @returns {{ init: string, close: string }}
|
|
237
|
+
*/
|
|
238
|
+
export function buildNextCommands(storyId) {
|
|
239
|
+
return {
|
|
240
|
+
init: `node .agents/scripts/single-story-init.js --story ${storyId}`,
|
|
241
|
+
close: `node .agents/scripts/single-story-close.js --story ${storyId} --cwd <main-repo>`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Run the diff backstop against a Story branch's actual change set.
|
|
247
|
+
*
|
|
248
|
+
* @param {{
|
|
249
|
+
* storyId: number,
|
|
250
|
+
* baseRef?: string,
|
|
251
|
+
* cwd?: string,
|
|
252
|
+
* computeFn?: typeof computeChangeSet,
|
|
253
|
+
* injectedRules?: object,
|
|
254
|
+
* }} args
|
|
255
|
+
* @returns {ReturnType<typeof checkLightDiffBackstop>}
|
|
256
|
+
*/
|
|
257
|
+
export function runDiffBackstop({
|
|
258
|
+
storyId,
|
|
259
|
+
baseRef = 'main',
|
|
260
|
+
cwd = process.cwd(),
|
|
261
|
+
computeFn = computeChangeSet,
|
|
262
|
+
injectedRules,
|
|
263
|
+
} = {}) {
|
|
264
|
+
const { files } = computeFn({
|
|
265
|
+
baseRef,
|
|
266
|
+
headRef: `story-${storyId}`,
|
|
267
|
+
cwd,
|
|
268
|
+
});
|
|
269
|
+
return checkLightDiffBackstop({ changedFiles: files, injectedRules });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Emit a JSON envelope on stdout (the machine surface) so a headless caller can
|
|
274
|
+
* branch on it. Human-readable log lines stay on stderr.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} envelope
|
|
277
|
+
* @param {boolean} pretty
|
|
278
|
+
*/
|
|
279
|
+
function emit(envelope, pretty) {
|
|
280
|
+
process.stdout.write(
|
|
281
|
+
pretty
|
|
282
|
+
? `${JSON.stringify(envelope, null, 2)}\n`
|
|
283
|
+
: `${JSON.stringify(envelope)}\n`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Backstop mode — re-check the actual diff.
|
|
289
|
+
*
|
|
290
|
+
* @param {{ story?: string, pretty: boolean }} values
|
|
291
|
+
* @returns {Promise<number>}
|
|
292
|
+
*/
|
|
293
|
+
async function runBackstopMode(values) {
|
|
294
|
+
const storyId = Number.parseInt(String(values.story ?? ''), 10);
|
|
295
|
+
if (!Number.isInteger(storyId) || storyId <= 0) {
|
|
296
|
+
process.stderr.write(HELP);
|
|
297
|
+
throw new Error('[deliver-light] --backstop requires --story <id>');
|
|
298
|
+
}
|
|
299
|
+
const result = runDiffBackstop({ storyId });
|
|
300
|
+
emit({ mode: 'backstop', storyId, ...result }, values.pretty);
|
|
301
|
+
if (result.blocked) {
|
|
302
|
+
Logger.warn(
|
|
303
|
+
`[deliver-light] diff backstop BLOCKED Story #${storyId}: ${result.reasons.join('; ')}`,
|
|
304
|
+
);
|
|
305
|
+
return EXIT_BACKSTOP_BLOCKED;
|
|
306
|
+
}
|
|
307
|
+
Logger.info(`[deliver-light] diff backstop clean for Story #${storyId}.`);
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Gate mode — judge the prompt and, on proceed, author the receipt Story.
|
|
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
|
+
*
|
|
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]
|
|
336
|
+
* @returns {Promise<number>}
|
|
337
|
+
*/
|
|
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
|
+
|
|
347
|
+
if (!values.prompt || String(values.prompt).trim() === '') {
|
|
348
|
+
process.stderr.write(HELP);
|
|
349
|
+
throw new Error('[deliver-light] --prompt <text> is required for the gate');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const gate = runLightGate({
|
|
353
|
+
creates: parseCsvPaths(values.creates),
|
|
354
|
+
refactors: parseCsvPaths(values.refactors),
|
|
355
|
+
acceptance: values.acceptance
|
|
356
|
+
? Number.parseInt(String(values.acceptance), 10)
|
|
357
|
+
: 1,
|
|
358
|
+
route: values.route,
|
|
359
|
+
reason: values.reason,
|
|
360
|
+
yes: values.yes === true,
|
|
361
|
+
});
|
|
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
|
+
|
|
375
|
+
if (gate.action !== 'proceed-light') {
|
|
376
|
+
emitFn(
|
|
377
|
+
{ mode: 'gate', action: gate.action, outcome: gate.outcome },
|
|
378
|
+
values.pretty,
|
|
379
|
+
);
|
|
380
|
+
Logger.warn(
|
|
381
|
+
`[deliver-light] gate did not proceed light (${gate.action}): ${gate.outcome.reasons.join('; ')}`,
|
|
382
|
+
);
|
|
383
|
+
return EXIT_NOT_PROCEED;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const provider = createProviderFn(resolveConfigFn());
|
|
387
|
+
const receipt = await createReceiptFn({
|
|
388
|
+
provider,
|
|
389
|
+
prompt: String(values.prompt),
|
|
390
|
+
changedFiles: [
|
|
391
|
+
...parseCsvPaths(values.creates),
|
|
392
|
+
...parseCsvPaths(values.refactors),
|
|
393
|
+
],
|
|
394
|
+
amends: values.amends ?? null,
|
|
395
|
+
});
|
|
396
|
+
emitFn(
|
|
397
|
+
{
|
|
398
|
+
mode: 'gate',
|
|
399
|
+
action: 'proceed-light',
|
|
400
|
+
storyId: receipt.storyId,
|
|
401
|
+
url: receipt.url,
|
|
402
|
+
nextCommands: buildNextCommands(receipt.storyId),
|
|
403
|
+
outcome: gate.outcome,
|
|
404
|
+
},
|
|
405
|
+
values.pretty,
|
|
406
|
+
);
|
|
407
|
+
Logger.info(
|
|
408
|
+
`[deliver-light] receipt Story #${receipt.storyId} created — hand off to single-story-init.js.`,
|
|
409
|
+
);
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function main() {
|
|
414
|
+
const { values } = parseArgs({
|
|
415
|
+
options: {
|
|
416
|
+
prompt: { type: 'string' },
|
|
417
|
+
creates: { type: 'string' },
|
|
418
|
+
refactors: { type: 'string' },
|
|
419
|
+
acceptance: { type: 'string' },
|
|
420
|
+
route: { type: 'string' },
|
|
421
|
+
reason: { type: 'string' },
|
|
422
|
+
amends: { type: 'string' },
|
|
423
|
+
yes: { type: 'boolean', default: false },
|
|
424
|
+
backstop: { type: 'boolean', default: false },
|
|
425
|
+
story: { type: 'string' },
|
|
426
|
+
pretty: { type: 'boolean', default: false },
|
|
427
|
+
help: { type: 'boolean', default: false },
|
|
428
|
+
},
|
|
429
|
+
allowPositionals: false,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
if (values.help) {
|
|
433
|
+
process.stdout.write(HELP);
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// stdout is a JSON stream — keep human-readable output on stderr.
|
|
438
|
+
routeAllOutputToStderr();
|
|
439
|
+
|
|
440
|
+
return values.backstop ? runBackstopMode(values) : runGateMode(values);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
runAsCli(import.meta.url, main, {
|
|
444
|
+
source: 'deliver-light',
|
|
445
|
+
propagateExitCode: true,
|
|
446
|
+
});
|
|
@@ -120,11 +120,14 @@ export const LITE_ROUTE_LABEL = 'route::lite';
|
|
|
120
120
|
* surfaces is where trivial-looking work stops
|
|
121
121
|
* being trivial.
|
|
122
122
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
123
|
+
* Exposed as the `ceilings` field on every {@link deriveStoryShape} decision
|
|
124
|
+
* and exported directly (Story #4740) so the `/deliver-light` suitability gate
|
|
125
|
+
* ({@link module:lib/orchestration/light-suitability}) judges a prompt's
|
|
126
|
+
* predicted footprint against the **same** ceilings the plan-time shape
|
|
127
|
+
* backstop applies — one source, so the light entry point and the plan path can
|
|
128
|
+
* never disagree about what shape is trivial.
|
|
126
129
|
*/
|
|
127
|
-
const STORY_SHAPE_CEILINGS = Object.freeze({
|
|
130
|
+
export const STORY_SHAPE_CEILINGS = Object.freeze({
|
|
128
131
|
maxChanges: 2,
|
|
129
132
|
maxAcceptance: 3,
|
|
130
133
|
maxNonCreateChanges: 1,
|