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
|
@@ -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
|
+
}
|