mandrel 2.12.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.
@@ -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 (24)
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. |
@@ -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
+ });
@@ -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
- * Module-private, exposed as the `ceilings` field on every
124
- * {@link deriveStoryShape} decision so there is no test-only export to
125
- * leave production-dead.
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,