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.
@@ -0,0 +1,439 @@
1
+ /**
2
+ * lib/orchestration/light-suitability.js — the `/deliver-light` suitability
3
+ * gate and diff backstop (Story #4740).
4
+ *
5
+ * ## Why a light entry point exists
6
+ *
7
+ * mandrel-bench 2.12.0 forensics attributed the framework arm's cost to
8
+ * **session multiplication** — repeated cold framework boots (2 for a one-file
9
+ * greenfield build, 4 for a change request) — where the bare control does
10
+ * comparable small work in a single session, lacking only the quality gates
11
+ * and the landing guarantee. `/deliver-light` closes that gap: one session
12
+ * straight to execution from an operator prompt, landing through the
13
+ * **unchanged** `single-story-close.js` path. This module is the reusable
14
+ * decision core the light workflow drives; it owns **no** git, branch, PR, or
15
+ * label mutation — those stay in the shared engine scripts.
16
+ *
17
+ * ## Four invariants keep it proportional, not a planning bypass
18
+ *
19
+ * 1. **Suitability gate ({@link deriveLightSuitability}).** The prompt's
20
+ * predicted footprint is judged by the **shared shape machinery**
21
+ * ({@link module:lib/orchestration/complexity-gate.deriveStoryShape} over
22
+ * {@link module:lib/orchestration/complexity-gate.STORY_SHAPE_CEILINGS})
23
+ * **and** a ledgered model verdict carrying a recorded reason
24
+ * ({@link resolveLedgeredVerdict}). Both must agree on `lite`; either
25
+ * falling short fails closed to `full`.
26
+ * 2. **Over-scope stops, never silently proceeds ({@link
27
+ * resolveLightGateOutcome}).** An over-ceiling prompt does **not**
28
+ * hard-fail — it STOPS and asks the operator to escalate to `/plan` or
29
+ * proceed light. Under `--yes` (unattended) it fails closed to
30
+ * recommending `/plan`.
31
+ * 3. **Diff-derived backstop ({@link checkLightDiffBackstop}).** After
32
+ * implementation the **actual** change set is re-checked with
33
+ * {@link module:lib/orchestration/review-depth.deriveChangeLevel} plus a
34
+ * file-count ceiling — the diff is the real scope signal — and an
35
+ * over-ceiling diff is blocked rather than landed silently.
36
+ * 4. **Minimal receipt Story ({@link buildReceiptStoryTicket}).** A
37
+ * `type::story` ticket is authored inline so `refs #`, history, telemetry,
38
+ * and the `agent::executing -> agent::done` state machine survive.
39
+ *
40
+ * Every function here is pure and total: inputs in, decision out, no I/O and no
41
+ * throws (except {@link buildReceiptStoryTicket}, which rejects an empty
42
+ * prompt — a receipt with no prompt has nothing to record).
43
+ *
44
+ * @module lib/orchestration/light-suitability
45
+ */
46
+
47
+ import { deriveStoryShape, STORY_SHAPE_CEILINGS } from './complexity-gate.js';
48
+ import { deriveChangeLevel } from './review-depth.js';
49
+
50
+ /**
51
+ * File-count ceiling for the **actual landed** change set the diff backstop
52
+ * ({@link checkLightDiffBackstop}) enforces. The predicted-shape ceiling caps
53
+ * `changes[]` at `maxChanges` (one artifact plus its test); the actual diff may
54
+ * legitimately run a touch wider (a generated projection, a snapshot), but a
55
+ * genuinely-light change stays small. Conservative by construction — a ceiling
56
+ * an operator could widen past what a single session safely absorbs is a
57
+ * ceiling that fails silently, so this is a framework constant, not a knob.
58
+ */
59
+ export const LIGHT_DIFF_CEILINGS = Object.freeze({
60
+ maxFiles: 4,
61
+ });
62
+
63
+ /**
64
+ * Coerce a candidate `maxFiles` ceiling into a positive integer, falling back
65
+ * to the framework default for anything malformed — a stray `0`, `-1`, or `NaN`
66
+ * must never widen (or zero out) the light diff ceiling.
67
+ *
68
+ * @param {unknown} value
69
+ * @param {number} fallback
70
+ * @returns {number}
71
+ */
72
+ function normalizeMaxFiles(value, fallback) {
73
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
74
+ return fallback;
75
+ }
76
+ return Math.floor(value);
77
+ }
78
+
79
+ /**
80
+ * Resolve the model's trivial-vs-standard verdict, held to the same ledgering
81
+ * contract the planner's authored verdict is
82
+ * ({@link module:lib/orchestration/complexity-gate.resolvePlannerRouteVerdict}):
83
+ * a `lite` route counts **only** with a non-empty recorded reason. A lite claim
84
+ * without a recorded reason, or any non-`lite` route, fails closed to `full` —
85
+ * an unaudited "trust me, it's small" never buys the light path.
86
+ *
87
+ * Pure and total.
88
+ *
89
+ * @param {{ route?: unknown, reason?: unknown }} [verdict]
90
+ * @returns {{
91
+ * route: 'lite'|'full',
92
+ * reason: string|null,
93
+ * recorded: boolean,
94
+ * note: string,
95
+ * }}
96
+ */
97
+ export function resolveLedgeredVerdict({ route, reason } = {}) {
98
+ const recordedReason = typeof reason === 'string' ? reason.trim() : '';
99
+ if (route !== 'lite') {
100
+ return {
101
+ route: 'full',
102
+ reason: recordedReason || null,
103
+ recorded: recordedReason !== '',
104
+ note: 'model verdict is not lite — standard /plan route',
105
+ };
106
+ }
107
+ if (recordedReason === '') {
108
+ return {
109
+ route: 'full',
110
+ reason: null,
111
+ recorded: false,
112
+ note: 'lite claim without a recorded reason — fails closed to full (the verdict must be ledgered)',
113
+ };
114
+ }
115
+ return {
116
+ route: 'lite',
117
+ reason: recordedReason,
118
+ recorded: true,
119
+ note: `model verdict: lite (recorded reason): ${recordedReason}`,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Judge whether an operator prompt's predicted footprint is suitable for the
125
+ * light path. The deterministic shape derivation and the ledgered model verdict
126
+ * must **both** agree on `lite`; anything else — an over-ceiling shape, a
127
+ * sensitive-path footprint, an unledgered verdict — resolves to `full` (the
128
+ * conservative default that routes the operator to `/plan`).
129
+ *
130
+ * Pure and total: never throws, never mutates its inputs.
131
+ *
132
+ * @param {{
133
+ * predictedChanges?: unknown,
134
+ * predictedAcceptance?: unknown,
135
+ * verdict?: { route?: unknown, reason?: unknown },
136
+ * injectedRules?: object,
137
+ * selectSensitivePathClassesFn?: Function,
138
+ * }} [args]
139
+ * @returns {{
140
+ * suitable: boolean,
141
+ * route: 'lite'|'full',
142
+ * shape: ReturnType<typeof deriveStoryShape>,
143
+ * ledger: ReturnType<typeof resolveLedgeredVerdict>,
144
+ * ceilings: typeof STORY_SHAPE_CEILINGS,
145
+ * reasons: string[],
146
+ * }}
147
+ */
148
+ export function deriveLightSuitability({
149
+ predictedChanges,
150
+ predictedAcceptance,
151
+ verdict,
152
+ injectedRules,
153
+ selectSensitivePathClassesFn,
154
+ } = {}) {
155
+ const ledger = resolveLedgeredVerdict(verdict ?? {});
156
+ const shape = deriveStoryShape({
157
+ changes: predictedChanges,
158
+ acceptance: predictedAcceptance,
159
+ injectedRules,
160
+ selectSensitivePathClassesFn,
161
+ });
162
+ const suitable = shape.route === 'lite' && ledger.route === 'lite';
163
+ return {
164
+ suitable,
165
+ route: suitable ? 'lite' : 'full',
166
+ shape,
167
+ ledger,
168
+ ceilings: STORY_SHAPE_CEILINGS,
169
+ reasons: [`shape: ${shape.reasons[0]}`, `verdict: ${ledger.note}`],
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Resolve what the light gate does with a suitability decision (Story #4740
175
+ * AC-3). Over-scope never hard-fails: it STOPS and asks the operator to choose,
176
+ * unless the run is unattended (`--yes`), where it fails closed to recommending
177
+ * `/plan` rather than silently proceeding light.
178
+ *
179
+ * - suitable → `proceed-light`
180
+ * - over-scope + attended (`yes:false`) → `ask-operator` (escalate | proceed)
181
+ * - over-scope + unattended (`yes:true`) → `escalate-plan`
182
+ *
183
+ * Pure and total.
184
+ *
185
+ * @param {{ suitability?: { suitable?: boolean, reasons?: string[] }, yes?: boolean }} [args]
186
+ * @returns {{ action: 'proceed-light'|'ask-operator'|'escalate-plan', options?: string[], reasons: string[] }}
187
+ */
188
+ export function resolveLightGateOutcome({ suitability, yes = false } = {}) {
189
+ const reasons = Array.isArray(suitability?.reasons)
190
+ ? [...suitability.reasons]
191
+ : [];
192
+
193
+ if (suitability?.suitable === true) {
194
+ return {
195
+ action: 'proceed-light',
196
+ reasons: [
197
+ ...reasons,
198
+ 'predicted shape and ledgered verdict both lite — proceed light',
199
+ ],
200
+ };
201
+ }
202
+
203
+ if (yes === true) {
204
+ return {
205
+ action: 'escalate-plan',
206
+ reasons: [
207
+ ...reasons,
208
+ '--yes on over-scope fails closed to /plan (never silently proceeds light)',
209
+ ],
210
+ };
211
+ }
212
+
213
+ return {
214
+ action: 'ask-operator',
215
+ options: ['escalate-plan', 'proceed-light'],
216
+ reasons: [
217
+ ...reasons,
218
+ 'predicted scope exceeds the light ceilings — STOP and ask the operator to escalate to /plan or proceed light',
219
+ ],
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Diff-derived backstop (Story #4740 AC-4): re-check the **actual** change set
225
+ * after implementation, because the diff — not the prompt — is the real scope
226
+ * signal. Blocks (rather than landing) when the diff intersects a sensitive-
227
+ * path class, exceeds the file-count ceiling, or cannot be classified. A clean
228
+ * result is the only path that lands light.
229
+ *
230
+ * Reuses close's own {@link module:lib/orchestration/review-depth.deriveChangeLevel}
231
+ * — one taxonomy, applied to the predicted shape at the gate and the actual
232
+ * diff here — so the two read points can never disagree about what is sensitive.
233
+ *
234
+ * Pure and total.
235
+ *
236
+ * @param {{
237
+ * changedFiles?: unknown,
238
+ * ceilings?: { maxFiles?: number },
239
+ * injectedRules?: object,
240
+ * selectSensitivePathClassesFn?: Function,
241
+ * }} [args]
242
+ * @returns {{
243
+ * blocked: boolean,
244
+ * level: 'low'|'high'|null,
245
+ * classes: string[],
246
+ * fileCount: number|null,
247
+ * ceilings: { maxFiles: number },
248
+ * reasons: string[],
249
+ * }}
250
+ */
251
+ export function checkLightDiffBackstop({
252
+ changedFiles,
253
+ ceilings,
254
+ injectedRules,
255
+ selectSensitivePathClassesFn,
256
+ } = {}) {
257
+ const maxFiles = normalizeMaxFiles(
258
+ ceilings?.maxFiles,
259
+ LIGHT_DIFF_CEILINGS.maxFiles,
260
+ );
261
+ const files = Array.isArray(changedFiles)
262
+ ? changedFiles.filter((f) => typeof f === 'string' && f.trim() !== '')
263
+ : null;
264
+
265
+ if (files === null || files.length === 0) {
266
+ return {
267
+ blocked: true,
268
+ level: null,
269
+ classes: [],
270
+ fileCount: files === null ? null : 0,
271
+ ceilings: { maxFiles },
272
+ reasons: [
273
+ 'actual change set is unknown or empty — cannot verify the diff is light; escalate to /plan',
274
+ ],
275
+ };
276
+ }
277
+
278
+ const { level, classes } = deriveChangeLevel({
279
+ changedFiles: files,
280
+ injectedRules,
281
+ selectSensitivePathClassesFn,
282
+ });
283
+
284
+ const reasons = [];
285
+ if (classes.length > 0) {
286
+ reasons.push(
287
+ `diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /plan (do not land light)`,
288
+ );
289
+ }
290
+ if (files.length > maxFiles) {
291
+ reasons.push(
292
+ `diff touches ${files.length} file(s) (> maxFiles ${maxFiles}) — escalate to /plan (do not land light)`,
293
+ );
294
+ }
295
+ if (level !== 'low' && classes.length === 0) {
296
+ reasons.push(
297
+ 'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /plan',
298
+ );
299
+ }
300
+
301
+ const blocked = reasons.length > 0;
302
+ return {
303
+ blocked,
304
+ level,
305
+ classes,
306
+ fileCount: files.length,
307
+ ceilings: { maxFiles },
308
+ reasons: blocked
309
+ ? reasons
310
+ : [
311
+ `diff is light: ${files.length} file(s) ≤ ${maxFiles}, no sensitive-path class — safe to land`,
312
+ ],
313
+ };
314
+ }
315
+
316
+ /** Cap on a receipt slug's length — keep the branch/id readable. */
317
+ const RECEIPT_SLUG_MAX = 48;
318
+
319
+ /**
320
+ * Derive a stable, lowercase, hyphenated slug from a prompt.
321
+ *
322
+ * @param {string} text
323
+ * @returns {string}
324
+ */
325
+ function slugifyPrompt(text) {
326
+ const slug = text
327
+ .toLowerCase()
328
+ .replace(/[^a-z0-9]+/g, '-')
329
+ .replace(/^-+|-+$/g, '')
330
+ .slice(0, RECEIPT_SLUG_MAX)
331
+ .replace(/-+$/g, '');
332
+ return slug === '' ? 'light-change' : slug;
333
+ }
334
+
335
+ /** Cap on a receipt title's length. */
336
+ const RECEIPT_TITLE_MAX = 72;
337
+
338
+ /**
339
+ * Coerce an `--amends` argument (`#123`, `123`, or `123` as a number) into a
340
+ * positive integer issue number, or `null` when absent/malformed.
341
+ *
342
+ * @param {unknown} amends
343
+ * @returns {number|null}
344
+ */
345
+ function normalizeAmends(amends) {
346
+ if (typeof amends === 'number' && Number.isInteger(amends) && amends > 0) {
347
+ return amends;
348
+ }
349
+ if (typeof amends === 'string') {
350
+ const match = amends.trim().match(/^#?(\d+)$/);
351
+ if (match) {
352
+ const n = Number.parseInt(match[1], 10);
353
+ if (Number.isInteger(n) && n > 0) return n;
354
+ }
355
+ }
356
+ return null;
357
+ }
358
+
359
+ /**
360
+ * One-line receipt title from the prompt, prefixed for an amendment.
361
+ *
362
+ * @param {string} text
363
+ * @param {number|null} amendsId
364
+ * @returns {string}
365
+ */
366
+ function deriveReceiptTitle(text, amendsId) {
367
+ const oneLine = text.replace(/\s+/g, ' ').trim();
368
+ const prefix = amendsId !== null ? `Amend #${amendsId}: ` : '';
369
+ const room = RECEIPT_TITLE_MAX - prefix.length;
370
+ const body =
371
+ oneLine.length > room
372
+ ? `${oneLine.slice(0, room - 1).trimEnd()}…`
373
+ : oneLine;
374
+ return `${prefix}${body}`;
375
+ }
376
+
377
+ /**
378
+ * Map an actual/predicted changed-file list into `changes[]` PathEntry objects
379
+ * for the receipt body. Every entry is recorded as `refactors-existing` — the
380
+ * conservative assumption, since the light path is not asserting creates.
381
+ *
382
+ * @param {unknown} changedFiles
383
+ * @returns {Array<{ path: string, assumption: string }>}
384
+ */
385
+ function toReceiptChanges(changedFiles) {
386
+ const list = Array.isArray(changedFiles) ? changedFiles : [];
387
+ const seen = new Set();
388
+ const entries = [];
389
+ for (const f of list) {
390
+ if (typeof f !== 'string' || f.trim() === '' || seen.has(f.trim()))
391
+ continue;
392
+ seen.add(f.trim());
393
+ entries.push({ path: f.trim(), assumption: 'refactors-existing' });
394
+ }
395
+ return entries;
396
+ }
397
+
398
+ /**
399
+ * Build the minimal receipt `type::story` ticket for the light path
400
+ * (Story #4740 AC-5) — the input `assemblePlanStories` / `createStoryIssues`
401
+ * consume, so the light path reuses the plan-persist story-creation surface
402
+ * rather than reimplementing issue authoring. The body carries the operator
403
+ * prompt (goal + spec) and the diff-derived footprint (`changes[]`), so history
404
+ * and `refs #<id>` on the commit survive.
405
+ *
406
+ * @param {{ prompt?: unknown, changedFiles?: unknown, amends?: unknown }} [args]
407
+ * @returns {{ slug: string, title: string, body: object, labels: string[] }}
408
+ */
409
+ export function buildReceiptStoryTicket({ prompt, changedFiles, amends } = {}) {
410
+ const text = typeof prompt === 'string' ? prompt.trim() : '';
411
+ if (text === '') {
412
+ throw new Error(
413
+ '[light-suitability] a non-empty prompt is required to build a receipt Story',
414
+ );
415
+ }
416
+ const amendsId = normalizeAmends(amends);
417
+ const amendNote = amendsId !== null ? ` Amends #${amendsId}.` : '';
418
+ const changes = toReceiptChanges(changedFiles);
419
+
420
+ return {
421
+ slug: slugifyPrompt(text),
422
+ title: deriveReceiptTitle(text, amendsId),
423
+ labels: [],
424
+ body: {
425
+ goal: `${text}${amendNote}`,
426
+ spec:
427
+ `Delivered via /deliver-light as a validated single-session change — ` +
428
+ `the /plan session is removed for genuinely small work while every ` +
429
+ `single-story-close gate runs byte-identical.${amendNote} ` +
430
+ `Operator prompt: ${text}`,
431
+ changes,
432
+ acceptance: [
433
+ 'The change described by the prompt is implemented and lands through ' +
434
+ 'the unchanged single-story-close path with every close gate passing.',
435
+ ],
436
+ verify: ['npm test (unit)'],
437
+ },
438
+ };
439
+ }