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.
- package/.agents/docs/workflows.md +2 -1
- package/.agents/rules/orchestration-error-handling.md +9 -1
- package/.agents/scripts/deliver-light.js +385 -0
- package/.agents/scripts/lib/audit-suite/audit-rules-reader.js +48 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -26
- package/.agents/scripts/lib/orchestration/complexity-gate.js +68 -17
- 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/plan-persist/run-plan-persist.js +6 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +186 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +21 -3
- package/.agents/scripts/plan-context.js +45 -3
- package/.agents/scripts/plan-persist.js +106 -9
- package/.agents/workflows/deliver-light.md +117 -0
- package/.agents/workflows/deliver.md +27 -29
- package/.agents/workflows/helpers/deliver-digest.md +126 -0
- package/.agents/workflows/helpers/deliver-reference.md +21 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +6 -0
- package/.agents/workflows/helpers/deliver-story.md +31 -35
- package/.agents/workflows/plan.md +66 -84
- package/docs/CHANGELOG.md +20 -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. |
|
|
@@ -46,8 +46,16 @@ success output is a per-turn tax on the whole session — not a one-time cost.
|
|
|
46
46
|
their drivers — emit them single-line (`JSON.stringify(x)`), never
|
|
47
47
|
pretty-printed (`null, 2` only adds resident bytes). Pretty output is
|
|
48
48
|
reserved for explicit opt-in flags (`--pretty`).
|
|
49
|
+
- **Streamed child output counts too (Story #4736).** A script that pipes a
|
|
50
|
+
child process's stdout/stderr through to the caller is emitting that output
|
|
51
|
+
as its own. `single-story-close.js` streamed every close-validation gate —
|
|
52
|
+
the whole of `npm test` included — and blew the budget by ~25× on a *passing*
|
|
53
|
+
close. Capture it to an artifact instead
|
|
54
|
+
([`single-story-close/gate-log.js`](../scripts/lib/orchestration/single-story-close/gate-log.js)),
|
|
55
|
+
emit the digest, and **replay the tail inline on failure** — the bound is a
|
|
56
|
+
success-path bound, and a red gate's evidence belongs in front of the caller.
|
|
49
57
|
- **Escape hatch.** `MANDREL_RESULT_DETAIL=inline` restores inline full
|
|
50
58
|
detail for interactive debugging; scripts using `emitTerseResult` honor it
|
|
51
|
-
automatically.
|
|
59
|
+
automatically. `AGENT_LOG_LEVEL=verbose` restores live gate streaming.
|
|
52
60
|
- **stdout purity is unchanged.** Scripts whose stdout is a machine contract
|
|
53
61
|
(Story #2278) keep logs on stderr; the digest is the *only* stdout line.
|
|
@@ -0,0 +1,385 @@
|
|
|
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 `escalate-plan`
|
|
27
|
+
* (`--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
|
+
* Usage:
|
|
33
|
+
* node .agents/scripts/deliver-light.js --prompt "<text>" \
|
|
34
|
+
* --creates path,path --acceptance 1 --route lite --reason "<why>"
|
|
35
|
+
* node .agents/scripts/deliver-light.js --prompt "<text>" --amends '#123' --route lite --reason "<why>"
|
|
36
|
+
* node .agents/scripts/deliver-light.js --backstop --story 4741
|
|
37
|
+
*
|
|
38
|
+
* Exit codes: 0 ok (proceed / clean backstop), 1 usage error, 2 the gate did
|
|
39
|
+
* not proceed light (ask-operator / escalate-plan), 3 the diff backstop blocked.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { parseArgs } from 'node:util';
|
|
43
|
+
|
|
44
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
45
|
+
import { resolveConfig } from './lib/config-resolver.js';
|
|
46
|
+
import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
|
|
47
|
+
import { computeChangeSet } from './lib/orchestration/change-set.js';
|
|
48
|
+
import {
|
|
49
|
+
buildReceiptStoryTicket,
|
|
50
|
+
checkLightDiffBackstop,
|
|
51
|
+
deriveLightSuitability,
|
|
52
|
+
resolveLightGateOutcome,
|
|
53
|
+
} from './lib/orchestration/light-suitability.js';
|
|
54
|
+
import {
|
|
55
|
+
assemblePlanStories,
|
|
56
|
+
createStoryIssues,
|
|
57
|
+
} from './lib/orchestration/plan-persist/story-ops.js';
|
|
58
|
+
import { createProvider } from './lib/provider-factory.js';
|
|
59
|
+
|
|
60
|
+
const HELP = `\
|
|
61
|
+
Usage:
|
|
62
|
+
deliver-light.js --prompt <text> [--creates csv] [--refactors csv]
|
|
63
|
+
[--acceptance n] [--route lite|full] [--reason <text>]
|
|
64
|
+
[--amends '#id'] [--yes]
|
|
65
|
+
deliver-light.js --backstop --story <id>
|
|
66
|
+
|
|
67
|
+
The thin /deliver-light entry point: suitability gate → inline receipt Story →
|
|
68
|
+
the same single-story-init.js / single-story-close.js engine /deliver uses.
|
|
69
|
+
|
|
70
|
+
Gate options:
|
|
71
|
+
--prompt <text> Operator prompt describing the change. Required for the gate.
|
|
72
|
+
--creates <csv> Predicted NEW file paths (comma-separated).
|
|
73
|
+
--refactors <csv> Predicted edited/existing file paths (comma-separated).
|
|
74
|
+
--acceptance <n> Predicted acceptance-criteria count (default 1).
|
|
75
|
+
--route <r> Ledgered model verdict route: lite | full.
|
|
76
|
+
--reason <text> Recorded reason for a lite verdict (required for lite).
|
|
77
|
+
--amends <#id> Mark this as an amendment of an existing issue.
|
|
78
|
+
--yes Unattended: over-scope fails closed to /plan (no prompt).
|
|
79
|
+
|
|
80
|
+
Backstop options:
|
|
81
|
+
--backstop Re-check the ACTUAL diff after implementation.
|
|
82
|
+
--story <id> Story issue number whose story-<id> branch to diff.
|
|
83
|
+
|
|
84
|
+
--pretty Pretty-print the JSON envelope.
|
|
85
|
+
--help Show this help.
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
/** Exit code when the gate did not resolve to proceed-light. */
|
|
89
|
+
const EXIT_NOT_PROCEED = 2;
|
|
90
|
+
/** Exit code when the diff backstop blocked the land. */
|
|
91
|
+
const EXIT_BACKSTOP_BLOCKED = 3;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Split a comma-separated path list into trimmed, non-empty entries.
|
|
95
|
+
*
|
|
96
|
+
* @param {string|undefined} csv
|
|
97
|
+
* @returns {string[]}
|
|
98
|
+
*/
|
|
99
|
+
export function parseCsvPaths(csv) {
|
|
100
|
+
if (typeof csv !== 'string' || csv.trim() === '') return [];
|
|
101
|
+
return csv
|
|
102
|
+
.split(',')
|
|
103
|
+
.map((s) => s.trim())
|
|
104
|
+
.filter((s) => s !== '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Assemble the predicted `changes[]` footprint from the declared creates /
|
|
109
|
+
* refactors lists — the input {@link deriveLightSuitability} shape-checks.
|
|
110
|
+
*
|
|
111
|
+
* @param {{ creates?: string[], refactors?: string[] }} args
|
|
112
|
+
* @returns {Array<{ path: string, assumption: string }>}
|
|
113
|
+
*/
|
|
114
|
+
export function buildPredictedChanges({ creates = [], refactors = [] } = {}) {
|
|
115
|
+
return [
|
|
116
|
+
...creates.map((path) => ({ path, assumption: 'creates' })),
|
|
117
|
+
...refactors.map((path) => ({ path, assumption: 'refactors-existing' })),
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Synthesize a predicted-acceptance array of the requested length — the shape
|
|
123
|
+
* gate reads the count, not the text, so placeholder strings suffice. A count
|
|
124
|
+
* below 1 yields a single-item array (a Story with no contract cannot be judged
|
|
125
|
+
* trivial, and the shape derivation rejects a zero-length acceptance anyway).
|
|
126
|
+
*
|
|
127
|
+
* @param {unknown} count
|
|
128
|
+
* @returns {string[]}
|
|
129
|
+
*/
|
|
130
|
+
export function synthesizeAcceptance(count) {
|
|
131
|
+
const n =
|
|
132
|
+
typeof count === 'number' && Number.isFinite(count) && count >= 1
|
|
133
|
+
? Math.floor(count)
|
|
134
|
+
: 1;
|
|
135
|
+
return Array.from({ length: n }, (_v, i) => `AC-${i + 1}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Run the suitability gate purely — no I/O. Returns the outcome envelope the
|
|
140
|
+
* CLI serializes. The prompt text and `--amends` target are deliberately **not**
|
|
141
|
+
* inputs: routing is shape-checked identically whether or not the change is an
|
|
142
|
+
* amendment (Story #4740 R3), and the prompt's text carries no routing signal —
|
|
143
|
+
* the predicted footprint does. Both flow into the receipt Story instead.
|
|
144
|
+
*
|
|
145
|
+
* @param {{
|
|
146
|
+
* creates?: string[],
|
|
147
|
+
* refactors?: string[],
|
|
148
|
+
* acceptance?: number,
|
|
149
|
+
* route?: string,
|
|
150
|
+
* reason?: string,
|
|
151
|
+
* yes?: boolean,
|
|
152
|
+
* injectedRules?: object,
|
|
153
|
+
* }} args
|
|
154
|
+
* @returns {{ action: string, suitability: object, outcome: object }}
|
|
155
|
+
*/
|
|
156
|
+
export function runLightGate({
|
|
157
|
+
creates = [],
|
|
158
|
+
refactors = [],
|
|
159
|
+
acceptance,
|
|
160
|
+
route,
|
|
161
|
+
reason,
|
|
162
|
+
yes = false,
|
|
163
|
+
injectedRules,
|
|
164
|
+
} = {}) {
|
|
165
|
+
const predictedChanges = buildPredictedChanges({ creates, refactors });
|
|
166
|
+
const suitability = deriveLightSuitability({
|
|
167
|
+
predictedChanges,
|
|
168
|
+
predictedAcceptance: synthesizeAcceptance(acceptance),
|
|
169
|
+
verdict: { route, reason },
|
|
170
|
+
injectedRules,
|
|
171
|
+
});
|
|
172
|
+
const outcome = resolveLightGateOutcome({ suitability, yes });
|
|
173
|
+
return { action: outcome.action, suitability, outcome };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Author the receipt Story via the plan-persist creation surface (reused, not
|
|
178
|
+
* reimplemented). Injectable seams keep it unit-testable without a network.
|
|
179
|
+
*
|
|
180
|
+
* @param {{
|
|
181
|
+
* provider: object,
|
|
182
|
+
* prompt: string,
|
|
183
|
+
* changedFiles?: string[],
|
|
184
|
+
* amends?: string|number|null,
|
|
185
|
+
* assembleFn?: typeof assemblePlanStories,
|
|
186
|
+
* createFn?: typeof createStoryIssues,
|
|
187
|
+
* }} args
|
|
188
|
+
* @returns {Promise<{ storyId: number, url: string|undefined, title: string }>}
|
|
189
|
+
*/
|
|
190
|
+
export async function createLightReceipt({
|
|
191
|
+
provider,
|
|
192
|
+
prompt,
|
|
193
|
+
changedFiles = [],
|
|
194
|
+
amends = null,
|
|
195
|
+
assembleFn = assemblePlanStories,
|
|
196
|
+
createFn = createStoryIssues,
|
|
197
|
+
} = {}) {
|
|
198
|
+
const ticket = buildReceiptStoryTicket({ prompt, changedFiles, amends });
|
|
199
|
+
const { stories } = assembleFn([ticket]);
|
|
200
|
+
const { created } = await createFn({ provider, stories });
|
|
201
|
+
const receipt = created[0];
|
|
202
|
+
if (!receipt || !Number.isInteger(receipt.id)) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
'[deliver-light] receipt Story creation did not return a numeric id',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return { storyId: receipt.id, url: receipt.url, title: receipt.title };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The engine hand-off — the SAME scripts `/deliver` uses. Named here as
|
|
212
|
+
* commands, never reimplemented: this is the whole of deliver-light's
|
|
213
|
+
* relationship to worktree/branch/lease/PR/merge mechanics.
|
|
214
|
+
*
|
|
215
|
+
* @param {number} storyId
|
|
216
|
+
* @returns {{ init: string, close: string }}
|
|
217
|
+
*/
|
|
218
|
+
export function buildNextCommands(storyId) {
|
|
219
|
+
return {
|
|
220
|
+
init: `node .agents/scripts/single-story-init.js --story ${storyId}`,
|
|
221
|
+
close: `node .agents/scripts/single-story-close.js --story ${storyId} --cwd <main-repo>`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Run the diff backstop against a Story branch's actual change set.
|
|
227
|
+
*
|
|
228
|
+
* @param {{
|
|
229
|
+
* storyId: number,
|
|
230
|
+
* baseRef?: string,
|
|
231
|
+
* cwd?: string,
|
|
232
|
+
* computeFn?: typeof computeChangeSet,
|
|
233
|
+
* injectedRules?: object,
|
|
234
|
+
* }} args
|
|
235
|
+
* @returns {ReturnType<typeof checkLightDiffBackstop>}
|
|
236
|
+
*/
|
|
237
|
+
export function runDiffBackstop({
|
|
238
|
+
storyId,
|
|
239
|
+
baseRef = 'main',
|
|
240
|
+
cwd = process.cwd(),
|
|
241
|
+
computeFn = computeChangeSet,
|
|
242
|
+
injectedRules,
|
|
243
|
+
} = {}) {
|
|
244
|
+
const { files } = computeFn({
|
|
245
|
+
baseRef,
|
|
246
|
+
headRef: `story-${storyId}`,
|
|
247
|
+
cwd,
|
|
248
|
+
});
|
|
249
|
+
return checkLightDiffBackstop({ changedFiles: files, injectedRules });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Emit a JSON envelope on stdout (the machine surface) so a headless caller can
|
|
254
|
+
* branch on it. Human-readable log lines stay on stderr.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} envelope
|
|
257
|
+
* @param {boolean} pretty
|
|
258
|
+
*/
|
|
259
|
+
function emit(envelope, pretty) {
|
|
260
|
+
process.stdout.write(
|
|
261
|
+
pretty
|
|
262
|
+
? `${JSON.stringify(envelope, null, 2)}\n`
|
|
263
|
+
: `${JSON.stringify(envelope)}\n`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Backstop mode — re-check the actual diff.
|
|
269
|
+
*
|
|
270
|
+
* @param {{ story?: string, pretty: boolean }} values
|
|
271
|
+
* @returns {Promise<number>}
|
|
272
|
+
*/
|
|
273
|
+
async function runBackstopMode(values) {
|
|
274
|
+
const storyId = Number.parseInt(String(values.story ?? ''), 10);
|
|
275
|
+
if (!Number.isInteger(storyId) || storyId <= 0) {
|
|
276
|
+
process.stderr.write(HELP);
|
|
277
|
+
throw new Error('[deliver-light] --backstop requires --story <id>');
|
|
278
|
+
}
|
|
279
|
+
const result = runDiffBackstop({ storyId });
|
|
280
|
+
emit({ mode: 'backstop', storyId, ...result }, values.pretty);
|
|
281
|
+
if (result.blocked) {
|
|
282
|
+
Logger.warn(
|
|
283
|
+
`[deliver-light] diff backstop BLOCKED Story #${storyId}: ${result.reasons.join('; ')}`,
|
|
284
|
+
);
|
|
285
|
+
return EXIT_BACKSTOP_BLOCKED;
|
|
286
|
+
}
|
|
287
|
+
Logger.info(`[deliver-light] diff backstop clean for Story #${storyId}.`);
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Gate mode — judge the prompt and, on proceed, author the receipt Story.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} values Parsed CLI values.
|
|
295
|
+
* @returns {Promise<number>}
|
|
296
|
+
*/
|
|
297
|
+
async function runGateMode(values) {
|
|
298
|
+
if (!values.prompt || String(values.prompt).trim() === '') {
|
|
299
|
+
process.stderr.write(HELP);
|
|
300
|
+
throw new Error('[deliver-light] --prompt <text> is required for the gate');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const gate = runLightGate({
|
|
304
|
+
creates: parseCsvPaths(values.creates),
|
|
305
|
+
refactors: parseCsvPaths(values.refactors),
|
|
306
|
+
acceptance: values.acceptance
|
|
307
|
+
? Number.parseInt(String(values.acceptance), 10)
|
|
308
|
+
: 1,
|
|
309
|
+
route: values.route,
|
|
310
|
+
reason: values.reason,
|
|
311
|
+
yes: values.yes === true,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (gate.action !== 'proceed-light') {
|
|
315
|
+
emit(
|
|
316
|
+
{ mode: 'gate', action: gate.action, outcome: gate.outcome },
|
|
317
|
+
values.pretty,
|
|
318
|
+
);
|
|
319
|
+
Logger.warn(
|
|
320
|
+
`[deliver-light] gate did not proceed light (${gate.action}): ${gate.outcome.reasons.join('; ')}`,
|
|
321
|
+
);
|
|
322
|
+
return EXIT_NOT_PROCEED;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const provider = createProvider(resolveConfig());
|
|
326
|
+
const receipt = await createLightReceipt({
|
|
327
|
+
provider,
|
|
328
|
+
prompt: String(values.prompt),
|
|
329
|
+
changedFiles: [
|
|
330
|
+
...parseCsvPaths(values.creates),
|
|
331
|
+
...parseCsvPaths(values.refactors),
|
|
332
|
+
],
|
|
333
|
+
amends: values.amends ?? null,
|
|
334
|
+
});
|
|
335
|
+
emit(
|
|
336
|
+
{
|
|
337
|
+
mode: 'gate',
|
|
338
|
+
action: 'proceed-light',
|
|
339
|
+
storyId: receipt.storyId,
|
|
340
|
+
url: receipt.url,
|
|
341
|
+
nextCommands: buildNextCommands(receipt.storyId),
|
|
342
|
+
outcome: gate.outcome,
|
|
343
|
+
},
|
|
344
|
+
values.pretty,
|
|
345
|
+
);
|
|
346
|
+
Logger.info(
|
|
347
|
+
`[deliver-light] receipt Story #${receipt.storyId} created — hand off to single-story-init.js.`,
|
|
348
|
+
);
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function main() {
|
|
353
|
+
const { values } = parseArgs({
|
|
354
|
+
options: {
|
|
355
|
+
prompt: { type: 'string' },
|
|
356
|
+
creates: { type: 'string' },
|
|
357
|
+
refactors: { type: 'string' },
|
|
358
|
+
acceptance: { type: 'string' },
|
|
359
|
+
route: { type: 'string' },
|
|
360
|
+
reason: { type: 'string' },
|
|
361
|
+
amends: { type: 'string' },
|
|
362
|
+
yes: { type: 'boolean', default: false },
|
|
363
|
+
backstop: { type: 'boolean', default: false },
|
|
364
|
+
story: { type: 'string' },
|
|
365
|
+
pretty: { type: 'boolean', default: false },
|
|
366
|
+
help: { type: 'boolean', default: false },
|
|
367
|
+
},
|
|
368
|
+
allowPositionals: false,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (values.help) {
|
|
372
|
+
process.stdout.write(HELP);
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// stdout is a JSON stream — keep human-readable output on stderr.
|
|
377
|
+
routeAllOutputToStderr();
|
|
378
|
+
|
|
379
|
+
return values.backstop ? runBackstopMode(values) : runGateMode(values);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
runAsCli(import.meta.url, main, {
|
|
383
|
+
source: 'deliver-light',
|
|
384
|
+
propagateExitCode: true,
|
|
385
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-suite/audit-rules-reader.js — the one synchronous reader of the
|
|
3
|
+
* `audit-rules.json` manifest, memoized for the process lifetime.
|
|
4
|
+
*
|
|
5
|
+
* The manifest is shipped framework configuration resolved from one fixed
|
|
6
|
+
* path per process, and the shape-derivation path reads it once per Story at
|
|
7
|
+
* resolve AND persist — an un-memoized read is pure repeated I/O (measured
|
|
8
|
+
* 221 µs/op raw vs 5.5 µs seamed on the run adhoc-4722-4723 audit). Only a
|
|
9
|
+
* successful parse is cached: a read failure stays a per-call throw so a
|
|
10
|
+
* caller can observe a manifest that becomes readable later. Tests never
|
|
11
|
+
* reach this read — they inject fixture rules through the callers'
|
|
12
|
+
* `injectedRules` seam.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
|
|
18
|
+
|
|
19
|
+
/** Process-lifetime memo of the parsed manifest (successful parses only). */
|
|
20
|
+
let auditRulesCache = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read and parse the `audit-rules.json` manifest synchronously from the
|
|
24
|
+
* project's configured `schemasRoot`. Shared by the synchronous, ticket-free
|
|
25
|
+
* readers (`resolveLensTier`, `selectLocalLenses`,
|
|
26
|
+
* `selectSensitivePathClasses`) so the path resolution and read-failure
|
|
27
|
+
* handling live in one place rather than being duplicated per reader.
|
|
28
|
+
*
|
|
29
|
+
* @returns {{ audits?: Record<string, object> }} Parsed manifest.
|
|
30
|
+
* @throws {Error} When the manifest cannot be read or parsed.
|
|
31
|
+
*/
|
|
32
|
+
export function readAuditRulesSync() {
|
|
33
|
+
if (auditRulesCache !== null) return auditRulesCache;
|
|
34
|
+
const config = resolveConfig();
|
|
35
|
+
const rulesPath = path.join(
|
|
36
|
+
PROJECT_ROOT,
|
|
37
|
+
getPaths(config).schemasRoot,
|
|
38
|
+
'audit-rules.json',
|
|
39
|
+
);
|
|
40
|
+
try {
|
|
41
|
+
auditRulesCache = JSON.parse(readFileSync(rulesPath, 'utf8'));
|
|
42
|
+
return auditRulesCache;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`audit-suite: failed to read audit-rules from ${rulesPath}: ${err.message}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -26,6 +26,7 @@ import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
|
|
|
26
26
|
import { softFailOrThrow } from '../degraded-mode.js';
|
|
27
27
|
import { gitSpawn } from '../git-utils.js';
|
|
28
28
|
import { withTimeout } from '../util/with-timeout.js';
|
|
29
|
+
import { readAuditRulesSync } from './audit-rules-reader.js';
|
|
29
30
|
|
|
30
31
|
const DEFAULT_GIT_TIMEOUT_MS = 30000;
|
|
31
32
|
|
|
@@ -101,32 +102,6 @@ export const LENS_TIERS = Object.freeze(['local', 'cumulative', 'global']);
|
|
|
101
102
|
* manifest cannot be read, or the registered entry carries a scope outside
|
|
102
103
|
* {@link LENS_TIERS}.
|
|
103
104
|
*/
|
|
104
|
-
/**
|
|
105
|
-
* Read and parse the `audit-rules.json` manifest synchronously from the
|
|
106
|
-
* project's configured `schemasRoot`. Shared by the synchronous, ticket-free
|
|
107
|
-
* readers ({@link resolveLensTier}, {@link selectLocalLenses}) so the path
|
|
108
|
-
* resolution and read-failure handling live in one place rather than being
|
|
109
|
-
* duplicated per reader.
|
|
110
|
-
*
|
|
111
|
-
* @returns {{ audits?: Record<string, object> }} Parsed manifest.
|
|
112
|
-
* @throws {Error} When the manifest cannot be read or parsed.
|
|
113
|
-
*/
|
|
114
|
-
function readAuditRulesSync() {
|
|
115
|
-
const config = resolveConfig();
|
|
116
|
-
const rulesPath = path.join(
|
|
117
|
-
PROJECT_ROOT,
|
|
118
|
-
getPaths(config).schemasRoot,
|
|
119
|
-
'audit-rules.json',
|
|
120
|
-
);
|
|
121
|
-
try {
|
|
122
|
-
return JSON.parse(readFileSync(rulesPath, 'utf8'));
|
|
123
|
-
} catch (err) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`audit-suite: failed to read audit-rules from ${rulesPath}: ${err.message}`,
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
105
|
export function resolveLensTier(lens) {
|
|
131
106
|
const rulesData = readAuditRulesSync();
|
|
132
107
|
|
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
* acceptance-critic dispatch — while every `single-story-close.js` gate
|
|
36
36
|
* runs unchanged. The `route::lite` label is a **human-visible hint
|
|
37
37
|
* only**, never the control signal: a lost label or an unread marker can
|
|
38
|
-
* no longer misroute delivery.
|
|
38
|
+
* no longer misroute delivery. Ahead of the shape read sits one
|
|
39
|
+
* shape-independent rule (Story #4736): a **single-Story run** is inline
|
|
40
|
+
* whatever its shape, because sub-agent isolation buys nothing when
|
|
41
|
+
* there is no concurrent sibling to isolate from.
|
|
39
42
|
*
|
|
40
43
|
* The shape taxonomy is deliberately the one `review-depth.js` already
|
|
41
44
|
* applies to the landed diff at close (`deriveChangeLevel` over the
|
|
@@ -117,11 +120,14 @@ export const LITE_ROUTE_LABEL = 'route::lite';
|
|
|
117
120
|
* surfaces is where trivial-looking work stops
|
|
118
121
|
* being trivial.
|
|
119
122
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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.
|
|
123
129
|
*/
|
|
124
|
-
const STORY_SHAPE_CEILINGS = Object.freeze({
|
|
130
|
+
export const STORY_SHAPE_CEILINGS = Object.freeze({
|
|
125
131
|
maxChanges: 2,
|
|
126
132
|
maxAcceptance: 3,
|
|
127
133
|
maxNonCreateChanges: 1,
|
|
@@ -561,37 +567,68 @@ function deriveStoryRouteFromBody(body, opts = {}) {
|
|
|
561
567
|
}
|
|
562
568
|
|
|
563
569
|
/**
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
|
|
570
|
+
* Best-effort route derivation for reporting, when the *mode* is already
|
|
571
|
+
* pinned by run topology and only `route` remains to be filled in. A body
|
|
572
|
+
* that will not parse yields `null` rather than throwing — the caller is not
|
|
573
|
+
* asking the shape to decide anything.
|
|
574
|
+
*
|
|
575
|
+
* @param {unknown} body
|
|
576
|
+
* @param {{ injectedRules?: object, selectSensitivePathClassesFn?: Function }} opts
|
|
577
|
+
* @returns {ReturnType<typeof deriveStoryShape>|null}
|
|
578
|
+
*/
|
|
579
|
+
function routeForReporting(body, opts) {
|
|
580
|
+
if (typeof body !== 'string' || body.trim() === '') return null;
|
|
581
|
+
return deriveStoryRouteFromBody(body, opts);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Decide how `/deliver` executes a Story.
|
|
586
|
+
*
|
|
587
|
+
* Two independent premises, checked in this order:
|
|
588
|
+
*
|
|
589
|
+
* 1. **Run topology (Story #4736).** A run delivering a *single* Story
|
|
590
|
+
* executes **inline**, whatever its shape. Sub-agent isolation is
|
|
591
|
+
* load-bearing only for CONCURRENT dispatch — two workers sharing a
|
|
592
|
+
* checkout would race on worktrees and branch refs — and a one-Story run
|
|
593
|
+
* has no sibling to race. It therefore pays the spawn premium (a boot is
|
|
594
|
+
* a cache WRITE at full rate, where an inline continuation is a cache read
|
|
595
|
+
* at ~10%; ~$1.43/M vs ~$1.07/M on comparable bench work) for nothing.
|
|
596
|
+
* This is a fact about the run, not about the work, so the shape gate's
|
|
597
|
+
* `enabled` switch — which governs *shape derivation* — does not reach it.
|
|
598
|
+
* 2. **Shape (Story #4722 AC-4/AC-5).** For a multi-Story run, the decision
|
|
599
|
+
* comes **from the Story body's own shape**, never from the `route::lite`
|
|
600
|
+
* label: a lite-shaped Story executes inline; everything else — a
|
|
601
|
+
* full-shaped body, a missing/unparseable body, or the gate disabled via
|
|
602
|
+
* `planning.complexityGate.enabled=false` — dispatches as a sub-agent,
|
|
603
|
+
* the conservative default.
|
|
573
604
|
*
|
|
574
605
|
* The label is read only to report hint consistency in `reasons`: with the
|
|
575
606
|
* label absent (or its write failed) a lite-shaped Story still runs inline,
|
|
576
607
|
* and with the label present on a full-shaped Story the shape wins.
|
|
577
608
|
*
|
|
578
|
-
* Inline execution removes model-side fan-out only
|
|
579
|
-
*
|
|
609
|
+
* Inline execution removes model-side fan-out only — it changes **where** the
|
|
610
|
+
* engine runs, never **what** runs. Every deterministic
|
|
611
|
+
* `single-story-close.js` gate, the PR to `main`, and the
|
|
612
|
+
* `story-deliver-terminal` envelope are identical in both modes; see the
|
|
580
613
|
* module header's non-negotiables.
|
|
581
614
|
*
|
|
582
615
|
* @param {{
|
|
583
616
|
* body?: unknown,
|
|
584
617
|
* labels?: unknown,
|
|
585
618
|
* config?: object,
|
|
619
|
+
* storyCount?: unknown,
|
|
586
620
|
* injectedRules?: object,
|
|
587
621
|
* selectSensitivePathClassesFn?: Function,
|
|
588
|
-
* }} [args]
|
|
622
|
+
* }} [args] `storyCount` is the number of Stories the invoking `/deliver` run
|
|
623
|
+
* resolved. Omitted (or not a positive integer) means "unknown run size",
|
|
624
|
+
* which falls through to the shape decision — never to an assumed 1.
|
|
589
625
|
* @returns {{ mode: 'inline'|'subagent', reasons: string[], route: ReturnType<typeof deriveStoryShape>|null }}
|
|
590
626
|
*/
|
|
591
627
|
export function resolveStoryDispatchMode({
|
|
592
628
|
body,
|
|
593
629
|
labels,
|
|
594
630
|
config,
|
|
631
|
+
storyCount,
|
|
595
632
|
injectedRules,
|
|
596
633
|
selectSensitivePathClassesFn,
|
|
597
634
|
} = {}) {
|
|
@@ -603,6 +640,20 @@ export function resolveStoryDispatchMode({
|
|
|
603
640
|
? `the ${LITE_ROUTE_LABEL} label is present (hint only — the derived shape is the control signal)`
|
|
604
641
|
: `the ${LITE_ROUTE_LABEL} label is absent (hint only — the derived shape is the control signal)`;
|
|
605
642
|
|
|
643
|
+
if (storyCount === 1) {
|
|
644
|
+
return {
|
|
645
|
+
mode: 'inline',
|
|
646
|
+
reasons: [
|
|
647
|
+
'single-Story run — execute deliver-story inline; sub-agent isolation is load-bearing only for concurrent dispatch, and a one-Story run has no sibling to race (close gates, PR, and terminal envelope unchanged)',
|
|
648
|
+
hintNote,
|
|
649
|
+
],
|
|
650
|
+
route: routeForReporting(body, {
|
|
651
|
+
injectedRules,
|
|
652
|
+
selectSensitivePathClassesFn,
|
|
653
|
+
}),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
606
657
|
const gate = resolveComplexityGate(config);
|
|
607
658
|
if (!gate.enabled) {
|
|
608
659
|
return {
|