mandrel 2.20.0 → 2.21.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/agents/story-worker.md +10 -0
- package/.agents/scripts/deliver-light.js +72 -8
- package/.agents/scripts/lib/config/temp-paths.js +62 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +68 -6
- package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
- package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/single-story-confirm-merge.js +1 -1
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +33 -0
- package/.agents/workflows/helpers/deliver-story.md +6 -3
- package/docs/CHANGELOG.md +8 -0
- package/package.json +1 -1
|
@@ -134,6 +134,16 @@ outside the worktree / branch / PR path — or committing it to local `main`
|
|
|
134
134
|
— is expressly **forbidden**; the close pipeline's push
|
|
135
135
|
(`single-story-close.js`) is the only sanctioned landing.
|
|
136
136
|
|
|
137
|
+
## Hold the turn until the envelope arrives (MUST)
|
|
138
|
+
|
|
139
|
+
Run close in the **foreground** and wait for it. Never background it,
|
|
140
|
+
never delegate it to a child, and never end your turn while it is still
|
|
141
|
+
running — "close is running" is not a return value. Ending early strands
|
|
142
|
+
the envelope in a turn nobody reads and costs your caller a recovery
|
|
143
|
+
cycle plus a full resume of you. Close does persist a copy to
|
|
144
|
+
`temp/orchestration/story-deliver-terminal-<id>.json`; that is your
|
|
145
|
+
caller's fallback, not your licence to return before the verdict.
|
|
146
|
+
|
|
137
147
|
## Return schema
|
|
138
148
|
|
|
139
149
|
The return contract is
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
* `proceed-light` it authors the receipt Story (via the plan-persist
|
|
25
25
|
* `createStoryIssues` surface) and prints the init/close hand-off. On
|
|
26
26
|
* over-scope it prints `ask-operator` (attended) or emits an `escalated`
|
|
27
|
-
* terminal envelope (`--yes`), never landing silently.
|
|
27
|
+
* terminal envelope (`--yes`), never landing silently. An attended
|
|
28
|
+
* `ask-operator` is answerable **either** way: `--operator-proceed-light`
|
|
29
|
+
* records the operator's proceed answer (Story #4815), which the gate
|
|
30
|
+
* applies only to a coarse size prediction and never to a risk rule.
|
|
28
31
|
* - **backstop** (`--backstop --story <id>`) — re-check the ACTUAL diff of
|
|
29
32
|
* the Story branch after implementation; exit non-zero when it exceeds the
|
|
30
33
|
* light ceilings, so an over-scope diff is blocked rather than landed.
|
|
@@ -81,7 +84,7 @@ Usage:
|
|
|
81
84
|
deliver-light.js --prompt <text> [--creates csv] [--refactors csv]
|
|
82
85
|
[--acceptance n] [--kinds csv] [--magnitude m]
|
|
83
86
|
[--uncertainty u] [--route lite|full] [--reason <text>]
|
|
84
|
-
[--amends '#id'] [--yes]
|
|
87
|
+
[--amends '#id'] [--operator-proceed-light <text>] [--yes]
|
|
85
88
|
deliver-light.js --backstop --story <id>
|
|
86
89
|
|
|
87
90
|
The thin /deliver-light entry point: suitability gate → inline receipt Story →
|
|
@@ -105,6 +108,15 @@ Gate options:
|
|
|
105
108
|
--route <r> Ledgered model verdict route: lite | full.
|
|
106
109
|
--reason <text> Recorded reason for a lite verdict (required for lite).
|
|
107
110
|
--amends <#id> Mark this as an amendment of an existing issue.
|
|
111
|
+
--operator-proceed-light <text>
|
|
112
|
+
Record the operator's "proceed light" answer to an
|
|
113
|
+
ask-operator gate, with their reason. Attended-only:
|
|
114
|
+
refused with --yes. Waives a coarse SIZE prediction
|
|
115
|
+
(change kinds, magnitude, uncertainty, deployable span)
|
|
116
|
+
only — sensitivity, migration span, and an unknown
|
|
117
|
+
footprint stay non-negotiable, the ledgered --route lite
|
|
118
|
+
verdict is still required, and the --backstop pass still
|
|
119
|
+
bounds the actual diff. Recorded in the receipt Story.
|
|
108
120
|
--yes Unattended: over-scope emits an escalated terminal
|
|
109
121
|
envelope and ENDS the session (no prompt, no fallback).
|
|
110
122
|
|
|
@@ -182,11 +194,14 @@ export function synthesizeAcceptance(count) {
|
|
|
182
194
|
* uncertainty?: string,
|
|
183
195
|
* route?: string,
|
|
184
196
|
* reason?: string,
|
|
197
|
+
* operatorProceedLight?: string,
|
|
185
198
|
* yes?: boolean,
|
|
186
199
|
* injectedRules?: object,
|
|
187
200
|
* }} args `kinds` / `magnitude` / `uncertainty` are the declared effort-and-risk
|
|
188
201
|
* axes the gate judges (Story #4764); omitting them declares no signal, not a
|
|
189
|
-
* small one — an unrecognized bucket fails closed.
|
|
202
|
+
* small one — an unrecognized bucket fails closed. `operatorProceedLight`
|
|
203
|
+
* carries the operator's recorded answer to an `ask-operator` outcome
|
|
204
|
+
* (Story #4815) and is adjudicated inside the gate, never applied here.
|
|
190
205
|
* @returns {{ action: string, suitability: object, outcome: object }}
|
|
191
206
|
*/
|
|
192
207
|
export function runLightGate({
|
|
@@ -198,6 +213,7 @@ export function runLightGate({
|
|
|
198
213
|
uncertainty,
|
|
199
214
|
route,
|
|
200
215
|
reason,
|
|
216
|
+
operatorProceedLight,
|
|
201
217
|
yes = false,
|
|
202
218
|
injectedRules,
|
|
203
219
|
} = {}) {
|
|
@@ -211,7 +227,11 @@ export function runLightGate({
|
|
|
211
227
|
verdict: { route, reason },
|
|
212
228
|
injectedRules,
|
|
213
229
|
});
|
|
214
|
-
const outcome = resolveLightGateOutcome({
|
|
230
|
+
const outcome = resolveLightGateOutcome({
|
|
231
|
+
suitability,
|
|
232
|
+
yes,
|
|
233
|
+
operatorOverride: operatorProceedLight,
|
|
234
|
+
});
|
|
215
235
|
return { action: outcome.action, suitability, outcome };
|
|
216
236
|
}
|
|
217
237
|
|
|
@@ -224,9 +244,11 @@ export function runLightGate({
|
|
|
224
244
|
* prompt: string,
|
|
225
245
|
* changedFiles?: string[],
|
|
226
246
|
* amends?: string|number|null,
|
|
247
|
+
* override?: object|null,
|
|
227
248
|
* assembleFn?: typeof assemblePlanStories,
|
|
228
249
|
* createFn?: typeof createStoryIssues,
|
|
229
|
-
* }} args
|
|
250
|
+
* }} args `override` is the applied operator scope override (Story #4815),
|
|
251
|
+
* recorded in the receipt body so the decision is auditable from the ticket.
|
|
230
252
|
* @returns {Promise<{ storyId: number, url: string|undefined, title: string }>}
|
|
231
253
|
*/
|
|
232
254
|
export async function createLightReceipt({
|
|
@@ -234,10 +256,16 @@ export async function createLightReceipt({
|
|
|
234
256
|
prompt,
|
|
235
257
|
changedFiles = [],
|
|
236
258
|
amends = null,
|
|
259
|
+
override = null,
|
|
237
260
|
assembleFn = assemblePlanStories,
|
|
238
261
|
createFn = createStoryIssues,
|
|
239
262
|
} = {}) {
|
|
240
|
-
const ticket = buildReceiptStoryTicket({
|
|
263
|
+
const ticket = buildReceiptStoryTicket({
|
|
264
|
+
prompt,
|
|
265
|
+
changedFiles,
|
|
266
|
+
amends,
|
|
267
|
+
override,
|
|
268
|
+
});
|
|
241
269
|
const { stories } = assembleFn([ticket]);
|
|
242
270
|
const { created } = await createFn({ provider, stories });
|
|
243
271
|
const receipt = created[0];
|
|
@@ -291,6 +319,19 @@ export function runDiffBackstop({
|
|
|
291
319
|
return checkLightDiffBackstop({ changedFiles: files, injectedRules });
|
|
292
320
|
}
|
|
293
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Was a non-blank `--operator-proceed-light` supplied? The gate core decides
|
|
324
|
+
* whether it *applies*; this only asks whether the operator typed one, so the
|
|
325
|
+
* attended-only refusal can fire before any adjudication.
|
|
326
|
+
*
|
|
327
|
+
* @param {{ 'operator-proceed-light'?: unknown }} values Parsed CLI values.
|
|
328
|
+
* @returns {boolean}
|
|
329
|
+
*/
|
|
330
|
+
export function hasOperatorOverride(values = {}) {
|
|
331
|
+
const raw = values['operator-proceed-light'];
|
|
332
|
+
return typeof raw === 'string' && raw.trim() !== '';
|
|
333
|
+
}
|
|
334
|
+
|
|
294
335
|
/**
|
|
295
336
|
* Emit a JSON envelope on stdout (the machine surface) so a headless caller can
|
|
296
337
|
* branch on it. Human-readable log lines stay on stderr.
|
|
@@ -341,8 +382,10 @@ async function runBackstopMode(values) {
|
|
|
341
382
|
* control flow rather than a claim the envelope makes about itself.
|
|
342
383
|
* - **`ask-operator`** is unchanged: the plain gate envelope and exit 2. It
|
|
343
384
|
* is not terminal — the operator has a choice to make, and manufacturing a
|
|
344
|
-
* terminal for it would end a session that is supposed to be waiting.
|
|
345
|
-
*
|
|
385
|
+
* terminal for it would end a session that is supposed to be waiting. The
|
|
386
|
+
* operator's proceed answer comes back as `--operator-proceed-light`.
|
|
387
|
+
* - **`proceed-light`** authors the receipt Story and prints the hand-off,
|
|
388
|
+
* carrying any applied `override` into both the receipt and the envelope.
|
|
346
389
|
*
|
|
347
390
|
* The injectable seams exist so the no-side-effect guarantee is testable
|
|
348
391
|
* without a network: a test asserts the escalate path never reaches them.
|
|
@@ -371,6 +414,17 @@ export async function runGateMode(values, deps = {}) {
|
|
|
371
414
|
throw new Error('[deliver-light] --prompt <text> is required for the gate');
|
|
372
415
|
}
|
|
373
416
|
|
|
417
|
+
// Attended-only, enforced loudly (Story #4815). Silently ignoring the flag
|
|
418
|
+
// under --yes would let an automated caller pass it as a hopeful no-op and
|
|
419
|
+
// read the resulting escalation as a bug; a usage error says which of the
|
|
420
|
+
// two the caller has to give up.
|
|
421
|
+
if (values.yes === true && hasOperatorOverride(values)) {
|
|
422
|
+
process.stderr.write(HELP);
|
|
423
|
+
throw new Error(
|
|
424
|
+
'[deliver-light] --operator-proceed-light is attended-only and cannot be combined with --yes: an unattended run has no operator whose answer this is, and over-scope must fail closed to /plan',
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
374
428
|
const gate = runLightGate({
|
|
375
429
|
creates: parseCsvPaths(values.creates),
|
|
376
430
|
refactors: parseCsvPaths(values.refactors),
|
|
@@ -382,6 +436,7 @@ export async function runGateMode(values, deps = {}) {
|
|
|
382
436
|
uncertainty: values.uncertainty,
|
|
383
437
|
route: values.route,
|
|
384
438
|
reason: values.reason,
|
|
439
|
+
operatorProceedLight: values['operator-proceed-light'],
|
|
385
440
|
yes: values.yes === true,
|
|
386
441
|
});
|
|
387
442
|
|
|
@@ -408,6 +463,7 @@ export async function runGateMode(values, deps = {}) {
|
|
|
408
463
|
return EXIT_NOT_PROCEED;
|
|
409
464
|
}
|
|
410
465
|
|
|
466
|
+
const override = gate.outcome.override ?? null;
|
|
411
467
|
const provider = createProviderFn(resolveConfigFn());
|
|
412
468
|
const receipt = await createReceiptFn({
|
|
413
469
|
provider,
|
|
@@ -417,6 +473,7 @@ export async function runGateMode(values, deps = {}) {
|
|
|
417
473
|
...parseCsvPaths(values.refactors),
|
|
418
474
|
],
|
|
419
475
|
amends: values.amends ?? null,
|
|
476
|
+
override,
|
|
420
477
|
});
|
|
421
478
|
emitFn(
|
|
422
479
|
{
|
|
@@ -424,11 +481,17 @@ export async function runGateMode(values, deps = {}) {
|
|
|
424
481
|
action: 'proceed-light',
|
|
425
482
|
storyId: receipt.storyId,
|
|
426
483
|
url: receipt.url,
|
|
484
|
+
...(override === null ? {} : { override }),
|
|
427
485
|
nextCommands: buildNextCommands(receipt.storyId),
|
|
428
486
|
outcome: gate.outcome,
|
|
429
487
|
},
|
|
430
488
|
values.pretty,
|
|
431
489
|
);
|
|
490
|
+
if (override !== null) {
|
|
491
|
+
Logger.warn(
|
|
492
|
+
`[deliver-light] operator scope override recorded on Story #${receipt.storyId}: waived "${override.overriddenCode}" — ${override.recordedReason}`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
432
495
|
Logger.info(
|
|
433
496
|
`[deliver-light] receipt Story #${receipt.storyId} created — hand off to single-story-init.js.`,
|
|
434
497
|
);
|
|
@@ -448,6 +511,7 @@ async function main() {
|
|
|
448
511
|
route: { type: 'string' },
|
|
449
512
|
reason: { type: 'string' },
|
|
450
513
|
amends: { type: 'string' },
|
|
514
|
+
'operator-proceed-light': { type: 'string' },
|
|
451
515
|
yes: { type: 'boolean', default: false },
|
|
452
516
|
backstop: { type: 'boolean', default: false },
|
|
453
517
|
story: { type: 'string' },
|
|
@@ -312,6 +312,68 @@ export function orchestrationLogDir(config) {
|
|
|
312
312
|
return path.join(anchorTempRoot(tempRootFrom(config)), ORCHESTRATION_DIRNAME);
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Basename of one Story's close gate log (Story #4816 lifted it here from
|
|
317
|
+
* `single-story-close/gate-log.js`).
|
|
318
|
+
*
|
|
319
|
+
* The writer that appends this file and the reader that uses its **freshness**
|
|
320
|
+
* to tell a live close from a dead one (`deliver-recover.js`) sit in different
|
|
321
|
+
* subtrees, and the reader importing the writer is the wrong edge to draw for
|
|
322
|
+
* a filename. Both take it from the module that already owns every other
|
|
323
|
+
* tempRoot path instead.
|
|
324
|
+
*
|
|
325
|
+
* `null` is the sink's no-Story sentinel and keeps its `unknown` spelling.
|
|
326
|
+
*
|
|
327
|
+
* @param {number|null} sid
|
|
328
|
+
* @returns {string}
|
|
329
|
+
*/
|
|
330
|
+
function closeGateLogName(sid) {
|
|
331
|
+
return `close-gates-${sid ?? 'unknown'}.log`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* `<tempRoot>/orchestration/close-gates-<sid>.log`.
|
|
336
|
+
*
|
|
337
|
+
* @param {number|null} sid
|
|
338
|
+
* @param {object} [config]
|
|
339
|
+
* @returns {string}
|
|
340
|
+
*/
|
|
341
|
+
export function closeGateLogPath(sid, config) {
|
|
342
|
+
return path.join(orchestrationLogDir(config), closeGateLogName(sid));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Basename of the persisted terminal envelope for one Story (Story #4816).
|
|
347
|
+
*
|
|
348
|
+
* @param {number} sid
|
|
349
|
+
* @returns {string}
|
|
350
|
+
*/
|
|
351
|
+
function storyTerminalEnvelopeName(sid) {
|
|
352
|
+
return `story-deliver-terminal-${storyId(sid)}.json`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `<tempRoot>/orchestration/story-deliver-terminal-<sid>.json` — the on-disk
|
|
357
|
+
* copy of the one terminal envelope a Story's close-and-land emits (Story
|
|
358
|
+
* #4816).
|
|
359
|
+
*
|
|
360
|
+
* Deliberately a sibling of the gate log rather than a per-Story temp dir
|
|
361
|
+
* entry: the envelope is a run artifact of the same close that writes
|
|
362
|
+
* `close-gates-<sid>.log`, and `deliver-recover.js` reads the pair together to
|
|
363
|
+
* tell a finished close from a live one. Sharing `orchestrationLogDir` also
|
|
364
|
+
* means it inherits main-checkout anchoring for free — the close runs inside
|
|
365
|
+
* `.worktrees/story-<sid>/` while the `/deliver` host reads from the main
|
|
366
|
+
* checkout, and an un-anchored path would put the envelope somewhere the
|
|
367
|
+
* router never looks.
|
|
368
|
+
*
|
|
369
|
+
* @param {number} sid
|
|
370
|
+
* @param {object} [config]
|
|
371
|
+
* @returns {string}
|
|
372
|
+
*/
|
|
373
|
+
export function storyTerminalEnvelopePath(sid, config) {
|
|
374
|
+
return path.join(orchestrationLogDir(config), storyTerminalEnvelopeName(sid));
|
|
375
|
+
}
|
|
376
|
+
|
|
315
377
|
const runId = (id) => {
|
|
316
378
|
if (!Number.isInteger(id) || id <= 0) {
|
|
317
379
|
throw new Error(`[temp-paths] runId must be a positive integer; got ${id}`);
|
|
@@ -259,23 +259,65 @@ function spansMigrationAndConsumers(paths) {
|
|
|
259
259
|
return migrations.length > 0 && migrations.length < paths.length;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Stable machine-readable identifiers for every reason a shape routes `full` —
|
|
264
|
+
* the `code` field on a {@link deriveStoryShape} decision (Story #4815).
|
|
265
|
+
*
|
|
266
|
+
* The prose in `reasons[]` is written for a human reading a gate envelope and
|
|
267
|
+
* is free to be re-worded; a caller that must **branch** on *which* rule
|
|
268
|
+
* objected reads this code instead. That distinction is load-bearing for the
|
|
269
|
+
* light path's operator override
|
|
270
|
+
* ({@link module:lib/orchestration/light-suitability.OVERRIDABLE_SHAPE_CODES}),
|
|
271
|
+
* which may waive a size *prediction* but never a risk rule: keying that
|
|
272
|
+
* decision off reason text would make a copy-edit a security change.
|
|
273
|
+
*
|
|
274
|
+
* Split three ways, and the grouping is the contract:
|
|
275
|
+
*
|
|
276
|
+
* - **Ceiling rules** — `change-kinds`, `magnitude`, `uncertainty`,
|
|
277
|
+
* `deployable-span`. Coarse predictions about size, enforced for real
|
|
278
|
+
* against ground truth by the diff backstop.
|
|
279
|
+
* - **Absolute rules** — `migration-span`, `sensitive-path`. Risk, not size.
|
|
280
|
+
* - **Unknown-footprint rejections** — `no-changes`, `unreadable-changes`,
|
|
281
|
+
* `glob-footprint`, `no-acceptance`, `classification-unavailable`,
|
|
282
|
+
* `unparseable-body`. Nothing was judged, so there is nothing to waive.
|
|
283
|
+
*
|
|
284
|
+
* A `lite` route carries `code: null`.
|
|
285
|
+
*/
|
|
286
|
+
export const SHAPE_CODES = Object.freeze({
|
|
287
|
+
CHANGE_KINDS: 'change-kinds',
|
|
288
|
+
MAGNITUDE: 'magnitude',
|
|
289
|
+
UNCERTAINTY: 'uncertainty',
|
|
290
|
+
DEPLOYABLE_SPAN: 'deployable-span',
|
|
291
|
+
MIGRATION_SPAN: 'migration-span',
|
|
292
|
+
SENSITIVE_PATH: 'sensitive-path',
|
|
293
|
+
NO_CHANGES: 'no-changes',
|
|
294
|
+
UNREADABLE_CHANGES: 'unreadable-changes',
|
|
295
|
+
GLOB_FOOTPRINT: 'glob-footprint',
|
|
296
|
+
NO_ACCEPTANCE: 'no-acceptance',
|
|
297
|
+
CLASSIFICATION_UNAVAILABLE: 'classification-unavailable',
|
|
298
|
+
UNPARSEABLE_BODY: 'unparseable-body',
|
|
299
|
+
});
|
|
300
|
+
|
|
262
301
|
/**
|
|
263
302
|
* Ordered effort/risk rules, evaluated in order; the first hit is the recorded
|
|
264
303
|
* reason for a `full` route. Every rule names an effort, risk, or uncertainty
|
|
265
304
|
* property of the work — none counts artifacts.
|
|
266
305
|
*
|
|
267
306
|
* @type {ReadonlyArray<{
|
|
307
|
+
* code: string,
|
|
268
308
|
* when: (shape: object, ceilings: typeof STORY_SHAPE_CEILINGS) => boolean,
|
|
269
309
|
* reason: (shape: object, ceilings: typeof STORY_SHAPE_CEILINGS) => string,
|
|
270
310
|
* }>}
|
|
271
311
|
*/
|
|
272
312
|
const EFFORT_RULES = Object.freeze([
|
|
273
313
|
{
|
|
314
|
+
code: SHAPE_CODES.CHANGE_KINDS,
|
|
274
315
|
when: (s, c) => s.kindCount > c.maxChangeKinds,
|
|
275
316
|
reason: (s, c) =>
|
|
276
317
|
`${s.kindCount} distinct change kinds (${s.changeKinds.join(', ')}) > maxChangeKinds ${c.maxChangeKinds} — an explicit multi-capability enumeration, not one capability; full route`,
|
|
277
318
|
},
|
|
278
319
|
{
|
|
320
|
+
code: SHAPE_CODES.MAGNITUDE,
|
|
279
321
|
when: (s, c) =>
|
|
280
322
|
MAGNITUDE_SCALE.indexOf(s.magnitude) >
|
|
281
323
|
MAGNITUDE_SCALE.indexOf(c.maxMagnitude),
|
|
@@ -283,6 +325,7 @@ const EFFORT_RULES = Object.freeze([
|
|
|
283
325
|
`declared magnitude "${s.magnitude}" > maxMagnitude "${c.maxMagnitude}" — a substantial rewrite is effort a single inline pass should not absorb, however few files it touches; full route`,
|
|
284
326
|
},
|
|
285
327
|
{
|
|
328
|
+
code: SHAPE_CODES.UNCERTAINTY,
|
|
286
329
|
when: (s, c) =>
|
|
287
330
|
UNCERTAINTY_SCALE.indexOf(s.uncertainty) >
|
|
288
331
|
UNCERTAINTY_SCALE.indexOf(c.maxUncertainty),
|
|
@@ -290,16 +333,19 @@ const EFFORT_RULES = Object.freeze([
|
|
|
290
333
|
`the shape is not determined by the request (uncertainty "${s.uncertainty}") — the design decisions /plan exists to resolve are still open; full route`,
|
|
291
334
|
},
|
|
292
335
|
{
|
|
336
|
+
code: SHAPE_CODES.DEPLOYABLE_SPAN,
|
|
293
337
|
when: (s, c) => s.deployables.length > c.maxDeployables,
|
|
294
338
|
reason: (s, c) =>
|
|
295
339
|
`footprint spans ${s.deployables.length} deployables (${s.deployables.join(', ')}) > maxDeployables ${c.maxDeployables} — clearly-epic scope; full route`,
|
|
296
340
|
},
|
|
297
341
|
{
|
|
342
|
+
code: SHAPE_CODES.MIGRATION_SPAN,
|
|
298
343
|
when: (s) => s.migrationSpan,
|
|
299
344
|
reason: () =>
|
|
300
345
|
'footprint pairs a migration with its consumers — clearly-epic scope; full route',
|
|
301
346
|
},
|
|
302
347
|
{
|
|
348
|
+
code: SHAPE_CODES.SENSITIVE_PATH,
|
|
303
349
|
when: (s) => s.sensitiveClasses.length > 0,
|
|
304
350
|
reason: (s) =>
|
|
305
351
|
`footprint intersects sensitive-path class(es) ${s.sensitiveClasses.join(', ')} — sensitivity wins over a small shape; full route (fresh acceptance critic retained)`,
|
|
@@ -307,15 +353,18 @@ const EFFORT_RULES = Object.freeze([
|
|
|
307
353
|
]);
|
|
308
354
|
|
|
309
355
|
/**
|
|
310
|
-
* First effort/risk rule the shape violates
|
|
356
|
+
* First effort/risk rule the shape violates as a `{ code, reason }` pair, or
|
|
357
|
+
* `null` when it clears them all.
|
|
311
358
|
*
|
|
312
359
|
* @param {object} shape
|
|
313
360
|
* @param {typeof STORY_SHAPE_CEILINGS} ceilings
|
|
314
|
-
* @returns {string|null}
|
|
361
|
+
* @returns {{ code: string, reason: string }|null}
|
|
315
362
|
*/
|
|
316
363
|
function firstEffortViolation(shape, ceilings) {
|
|
317
364
|
for (const rule of EFFORT_RULES) {
|
|
318
|
-
if (rule.when(shape, ceilings))
|
|
365
|
+
if (rule.when(shape, ceilings)) {
|
|
366
|
+
return { code: rule.code, reason: rule.reason(shape, ceilings) };
|
|
367
|
+
}
|
|
319
368
|
}
|
|
320
369
|
return null;
|
|
321
370
|
}
|
|
@@ -654,10 +703,13 @@ function buildEffortShape({
|
|
|
654
703
|
* @returns {{
|
|
655
704
|
* route: ComplexityRoute,
|
|
656
705
|
* reasons: string[],
|
|
706
|
+
* code: string|null,
|
|
657
707
|
* shape: ReturnType<typeof buildEffortShape>|null,
|
|
658
708
|
* ceilings: typeof STORY_SHAPE_CEILINGS,
|
|
659
709
|
* preserves: typeof LITE_PATH_INVARIANTS,
|
|
660
|
-
* }}
|
|
710
|
+
* }} `code` is the stable {@link SHAPE_CODES} identifier for the rule that
|
|
711
|
+
* rejected the shape (`null` on `lite`) — the field a caller branches on,
|
|
712
|
+
* since `reasons[]` is human prose and free to be re-worded.
|
|
661
713
|
*/
|
|
662
714
|
export function deriveStoryShape({
|
|
663
715
|
changes,
|
|
@@ -670,9 +722,10 @@ export function deriveStoryShape({
|
|
|
670
722
|
} = {}) {
|
|
671
723
|
const ceilings = STORY_SHAPE_CEILINGS;
|
|
672
724
|
const preserves = LITE_PATH_INVARIANTS;
|
|
673
|
-
const decide = (route, reason, shape = null) => ({
|
|
725
|
+
const decide = (route, code, reason, shape = null) => ({
|
|
674
726
|
route,
|
|
675
727
|
reasons: [reason],
|
|
728
|
+
code,
|
|
676
729
|
shape,
|
|
677
730
|
ceilings,
|
|
678
731
|
preserves,
|
|
@@ -681,6 +734,7 @@ export function deriveStoryShape({
|
|
|
681
734
|
if (!Array.isArray(changes) || changes.length === 0) {
|
|
682
735
|
return decide(
|
|
683
736
|
'full',
|
|
737
|
+
SHAPE_CODES.NO_CHANGES,
|
|
684
738
|
'no changes[] declared — the footprint is unknown, so the work cannot be judged trivial; conservative full route',
|
|
685
739
|
);
|
|
686
740
|
}
|
|
@@ -691,6 +745,7 @@ export function deriveStoryShape({
|
|
|
691
745
|
} catch (err) {
|
|
692
746
|
return decide(
|
|
693
747
|
'full',
|
|
748
|
+
SHAPE_CODES.UNREADABLE_CHANGES,
|
|
694
749
|
`changes[] could not be read (${err?.message ?? err}) — unknown footprint; conservative full route`,
|
|
695
750
|
);
|
|
696
751
|
}
|
|
@@ -714,6 +769,7 @@ export function deriveStoryShape({
|
|
|
714
769
|
if (entries.some((e) => e.isGlob)) {
|
|
715
770
|
return decide(
|
|
716
771
|
'full',
|
|
772
|
+
SHAPE_CODES.GLOB_FOOTPRINT,
|
|
717
773
|
'changes[] contains a glob path — unknown footprint width; conservative full route',
|
|
718
774
|
shape,
|
|
719
775
|
);
|
|
@@ -721,13 +777,16 @@ export function deriveStoryShape({
|
|
|
721
777
|
if (shape.acceptanceCount === 0) {
|
|
722
778
|
return decide(
|
|
723
779
|
'full',
|
|
780
|
+
SHAPE_CODES.NO_ACCEPTANCE,
|
|
724
781
|
'no acceptance criteria — the contract cannot be judged trivial; conservative full route',
|
|
725
782
|
shape,
|
|
726
783
|
);
|
|
727
784
|
}
|
|
728
785
|
|
|
729
786
|
const violation = firstEffortViolation(shape, ceilings);
|
|
730
|
-
if (violation !== null)
|
|
787
|
+
if (violation !== null) {
|
|
788
|
+
return decide('full', violation.code, violation.reason, shape);
|
|
789
|
+
}
|
|
731
790
|
|
|
732
791
|
if (level !== 'low') {
|
|
733
792
|
// `deriveChangeLevel` degraded to its null fail-safe (unreadable
|
|
@@ -735,6 +794,7 @@ export function deriveStoryShape({
|
|
|
735
794
|
// non-sensitive, and a classification failure must never buy lite.
|
|
736
795
|
return decide(
|
|
737
796
|
'full',
|
|
797
|
+
SHAPE_CODES.CLASSIFICATION_UNAVAILABLE,
|
|
738
798
|
'sensitive-path classification unavailable — cannot verify the footprint is non-sensitive; conservative full route',
|
|
739
799
|
shape,
|
|
740
800
|
);
|
|
@@ -742,6 +802,7 @@ export function deriveStoryShape({
|
|
|
742
802
|
|
|
743
803
|
return decide(
|
|
744
804
|
'lite',
|
|
805
|
+
null,
|
|
745
806
|
`trivial shape: ${shape.kindCount} change kind(s) (${shape.changeKinds.join(', ')}) ≤ ${ceilings.maxChangeKinds} across ${shape.siteCount} site(s), magnitude ${shape.magnitude} ≤ ${ceilings.maxMagnitude}, shape ${shape.uncertainty}, no epic-scope span, no sensitive-path class — inline-eligible; non-negotiables preserved`,
|
|
746
807
|
shape,
|
|
747
808
|
);
|
|
@@ -771,6 +832,7 @@ function deriveStoryRouteFromBody(body, opts = {}) {
|
|
|
771
832
|
reasons: [
|
|
772
833
|
`Story body is unparseable (${err?.message ?? err}) — shape unknown; conservative full route`,
|
|
773
834
|
],
|
|
835
|
+
code: SHAPE_CODES.UNPARSEABLE_BODY,
|
|
774
836
|
shape: null,
|
|
775
837
|
ceilings: STORY_SHAPE_CEILINGS,
|
|
776
838
|
preserves: LITE_PATH_INVARIANTS,
|