mandrel 2.20.0 → 2.22.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.
Files changed (46) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +15 -0
  3. package/.agents/instructions.md +14 -17
  4. package/.agents/rules/git-conventions.md +1 -1
  5. package/.agents/rules/known-tooling-behavior.md +114 -0
  6. package/.agents/scripts/check-context-budget.js +134 -2
  7. package/.agents/scripts/deliver-light.js +72 -8
  8. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  9. package/.agents/scripts/lib/config/temp-paths.js +113 -7
  10. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  11. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  12. package/.agents/scripts/lib/label-constants.js +12 -1
  13. package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
  14. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  15. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  16. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  17. package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
  18. package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
  19. package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
  20. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  21. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  22. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  24. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  25. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  26. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  27. package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
  28. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  31. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
  32. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  33. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
  34. package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
  35. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  36. package/.agents/scripts/lib/temp-retention.js +23 -8
  37. package/.agents/scripts/resolve-stories.js +52 -33
  38. package/.agents/scripts/single-story-confirm-merge.js +6 -8
  39. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  40. package/.agents/workflows/helpers/deliver-light.md +45 -5
  41. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  42. package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
  43. package/.agents/workflows/helpers/deliver-story.md +8 -5
  44. package/.agents/workflows/helpers/plan-reference.md +5 -4
  45. package/docs/CHANGELOG.md +28 -0
  46. package/package.json +1 -1
@@ -35,12 +35,31 @@
35
35
  * resumption speak one language instead of two dialects for one state.
36
36
  */
37
37
 
38
+ import nodeFs from 'node:fs';
39
+
40
+ import {
41
+ closeGateLogPath,
42
+ storyTerminalEnvelopePath,
43
+ } from '../config/temp-paths.js';
38
44
  import { gh as defaultGh } from '../gh-exec.js';
39
45
  import { gitSpawn as defaultGitSpawn, getStoryBranch } from '../git-utils.js';
40
46
  import { deriveChecksStatus } from './merge-poll.js';
41
47
  import { NEXT_COMMANDS } from './story-deliver-terminal.js';
42
48
  import { STATE_LABELS } from './ticketing.js';
43
49
 
50
+ /**
51
+ * How recently the gate log must have been appended for the close that writes
52
+ * it to count as live (Story #4816).
53
+ *
54
+ * The window is generous on purpose. Gate output arrives in bursts — a single
55
+ * `npm test` gate can run for a long stretch between lines — so a tight window
56
+ * would read a slow-but-healthy close as dead and re-open the exact
57
+ * misdiagnosis this exists to remove. Being wrong in the other direction is
58
+ * cheap: the verdict for a live close is "re-run this read-only probe", which
59
+ * costs nothing if the close has in fact already exited.
60
+ */
61
+ const CLOSE_IN_FLIGHT_WINDOW_MS = 120_000;
62
+
44
63
  /**
45
64
  * Probe the ticket: state labels, issue open/closed, and the lease holder.
46
65
  *
@@ -131,6 +150,171 @@ export async function probePr({ storyBranch, gh = defaultGh }) {
131
150
  }
132
151
  }
133
152
 
153
+ /**
154
+ * Probe the two on-disk artifacts a close leaves behind (Story #4816): the
155
+ * persisted terminal envelope and the gate log.
156
+ *
157
+ * These exist because the label-and-PR probes above cannot see the difference
158
+ * between an implementation that died and a close that is still running —
159
+ * both read `agent::executing` with no PR for the whole gate chain. The
160
+ * artifacts can: a persisted envelope means the close already reached a
161
+ * verdict, and a recently-appended gate log means one is mid-chain right now.
162
+ * Gate-log freshness is exactly the signal operators were already using by
163
+ * hand to tell the two apart, which is the argument for reading it here
164
+ * instead of expecting them to know.
165
+ *
166
+ * Never throws: an unreadable or absent artifact is a `null` reading, and the
167
+ * table falls back to the label-only verdict it always had.
168
+ *
169
+ * @param {{
170
+ * storyId: number,
171
+ * config?: object,
172
+ * fsImpl?: typeof nodeFs,
173
+ * nowMs?: number,
174
+ * windowMs?: number,
175
+ * }} args
176
+ * @returns {{
177
+ * envelope: object|null,
178
+ * envelopePath: string|null,
179
+ * envelopeMtimeMs: number|null,
180
+ * gateLogPath: string|null,
181
+ * gateLogAgeMs: number|null,
182
+ * gateLogMtimeMs: number|null,
183
+ * gateLogFresh: boolean,
184
+ * }}
185
+ */
186
+ export function probeCloseArtifacts({
187
+ storyId,
188
+ config,
189
+ fsImpl = nodeFs,
190
+ nowMs = Date.now(),
191
+ windowMs = CLOSE_IN_FLIGHT_WINDOW_MS,
192
+ }) {
193
+ const empty = {
194
+ envelope: null,
195
+ envelopePath: null,
196
+ envelopeMtimeMs: null,
197
+ gateLogPath: null,
198
+ gateLogAgeMs: null,
199
+ gateLogMtimeMs: null,
200
+ gateLogFresh: false,
201
+ };
202
+ let envelopePath = null;
203
+ let gateLogPath = null;
204
+ try {
205
+ envelopePath = storyTerminalEnvelopePath(storyId, config);
206
+ gateLogPath = closeGateLogPath(storyId, config);
207
+ } catch {
208
+ return empty;
209
+ }
210
+
211
+ let envelope = null;
212
+ let envelopeMtimeMs = null;
213
+ try {
214
+ const parsed = JSON.parse(fsImpl.readFileSync(envelopePath, 'utf8'));
215
+ // A parsed non-object (or an array) is not an envelope; treat it as
216
+ // absent rather than handing the table something it cannot read fields
217
+ // off of.
218
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
219
+ envelope = parsed;
220
+ envelopeMtimeMs = fsImpl.statSync(envelopePath).mtimeMs;
221
+ }
222
+ } catch {
223
+ envelope = null;
224
+ envelopeMtimeMs = null;
225
+ }
226
+
227
+ let gateLogMtimeMs = null;
228
+ try {
229
+ gateLogMtimeMs = fsImpl.statSync(gateLogPath).mtimeMs;
230
+ } catch {
231
+ gateLogMtimeMs = null;
232
+ }
233
+ const gateLogAgeMs =
234
+ gateLogMtimeMs === null ? null : Math.max(0, nowMs - gateLogMtimeMs);
235
+
236
+ return {
237
+ envelope,
238
+ envelopePath,
239
+ envelopeMtimeMs,
240
+ gateLogPath,
241
+ gateLogAgeMs,
242
+ gateLogMtimeMs,
243
+ gateLogFresh: gateLogAgeMs !== null && gateLogAgeMs <= windowMs,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Is a close running right now, outranking whatever a persisted envelope says?
249
+ *
250
+ * A persisted envelope is definitive about the close that wrote it — but a
251
+ * Story can be closed more than once (a `pending` wait resumed, a red gate
252
+ * fixed and re-run), and a *stale* envelope from the previous attempt must not
253
+ * out-argue a gate log the current attempt is appending to as we read. So the
254
+ * live signal wins whenever the gate log is both fresh and fresher than the
255
+ * envelope.
256
+ *
257
+ * @param {object} artifacts A {@link probeCloseArtifacts} reading.
258
+ * @returns {boolean}
259
+ */
260
+ function closeLooksLive(artifacts) {
261
+ if (!artifacts?.gateLogFresh) return false;
262
+ if (artifacts.envelopeMtimeMs === null) return true;
263
+ return artifacts.gateLogMtimeMs > artifacts.envelopeMtimeMs;
264
+ }
265
+
266
+ /**
267
+ * The verdict for a Story whose close already finished but whose envelope
268
+ * never reached the caller — the orphaned-turn shape (Story #4816).
269
+ *
270
+ * Nothing is re-derived here: the envelope on disk is the same
271
+ * schema-validated object the close emitted, so its own `status` and
272
+ * `nextCommand` are relayed rather than a second opinion invented from
273
+ * labels. A `landed` envelope carries a null next command, and the honest
274
+ * follow-up for a landed-but-mislabelled Story is the idempotent confirm.
275
+ */
276
+ function envelopeOnDiskVerdict({ storyId, artifacts, evidence }) {
277
+ const { envelope, envelopePath } = artifacts;
278
+ return {
279
+ shape: 'close-envelope-on-disk',
280
+ nextCommand: envelope.nextCommand ?? NEXT_COMMANDS.confirmMerge(storyId),
281
+ detail:
282
+ `The close for this Story already reached a terminal verdict — \`${envelope.status}\` ` +
283
+ `at phase \`${envelope.phase}\` — but the label still reads mid-flight, which is the ` +
284
+ `signature of a worker turn that ended before it could relay the envelope. The close ` +
285
+ `itself is not in doubt: read the full envelope at \`${envelopePath}\` rather than ` +
286
+ `re-deriving its state, and run the command below (the envelope's own \`nextCommand\`, ` +
287
+ `or the idempotent confirm when it landed and named none).`,
288
+ evidence,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * The verdict for a close that is running as we probe (Story #4816).
294
+ *
295
+ * The next command is this probe again. That is not a shrug: there is no
296
+ * attach-to-a-running-close surface, the close needs nothing from anyone, and
297
+ * every *other* command an operator might reach for here is actively harmful
298
+ * — which is why the detail names the re-init hazard explicitly instead of
299
+ * leaving it implied.
300
+ */
301
+ function closeInFlightVerdict({ storyId, artifacts, evidence }) {
302
+ const seconds = Math.round((artifacts.gateLogAgeMs ?? 0) / 1000);
303
+ return {
304
+ shape: 'close-in-flight',
305
+ nextCommand: NEXT_COMMANDS.recover(storyId),
306
+ detail:
307
+ `A close is RUNNING for this Story right now: \`${artifacts.gateLogPath}\` was appended ` +
308
+ `${seconds}s ago. \`agent::executing\` with no PR does NOT mean the work stalled here — ` +
309
+ `the implementation is done and the close is mid-gate-chain, before its push. ` +
310
+ `**Do not run \`single-story-init.js\`**: re-initializing the worktree ` +
311
+ `underneath a live close risks a second close racing the first on one PR (double label ` +
312
+ `flip, double post-land tail). Let it finish — it emits its own terminal envelope and ` +
313
+ `persists a copy — then re-run the command below for a settled verdict.`,
314
+ evidence,
315
+ };
316
+ }
317
+
134
318
  /**
135
319
  * The decision table. Pure: every input is an already-observed probe, so the
136
320
  * mapping is testable without git, GitHub, or a clock.
@@ -138,10 +322,22 @@ export async function probePr({ storyBranch, gh = defaultGh }) {
138
322
  * Returns exactly one `{ shape, nextCommand, evidence[], detail }` — never a
139
323
  * list of candidates.
140
324
  *
141
- * @param {{ storyId: number, ticket: object, branch: object, pr: object|null }} probes
325
+ * @param {{
326
+ * storyId: number,
327
+ * ticket: object,
328
+ * branch: object,
329
+ * pr: object|null,
330
+ * closeArtifacts?: object,
331
+ * }} probes
142
332
  * @returns {{ shape: string, nextCommand: string|null, detail: string, evidence: string[] }}
143
333
  */
144
- export function decideRecovery({ storyId, ticket, branch, pr }) {
334
+ export function decideRecovery({
335
+ storyId,
336
+ ticket,
337
+ branch,
338
+ pr,
339
+ closeArtifacts,
340
+ }) {
145
341
  const evidence = [
146
342
  `label=${ticket?.stateLabel ?? 'none'}`,
147
343
  `issue=${ticket?.issueState ?? 'unknown'}`,
@@ -151,6 +347,13 @@ export function decideRecovery({ storyId, ticket, branch, pr }) {
151
347
  `branch.remote=${branch?.remote ?? false}`,
152
348
  `worktree=${branch?.worktreePath ?? 'none'}`,
153
349
  `lease=${ticket?.lease ?? 'unclaimed'}`,
350
+ `closeEnvelope=${closeArtifacts?.envelope ? closeArtifacts.envelope.status : 'none'}`,
351
+ `gateLogAge=${
352
+ closeArtifacts?.gateLogAgeMs === null ||
353
+ closeArtifacts?.gateLogAgeMs === undefined
354
+ ? 'none'
355
+ : `${Math.round(closeArtifacts.gateLogAgeMs / 1000)}s`
356
+ }`,
154
357
  ];
155
358
 
156
359
  const label = ticket?.stateLabel;
@@ -242,12 +445,33 @@ export function decideRecovery({ storyId, ticket, branch, pr }) {
242
445
  evidence,
243
446
  };
244
447
  }
448
+ // Story #4816 — the close artifacts get the first word here, and ONLY
449
+ // here. Every other row of this table describes a state whose evidence is
450
+ // already unambiguous; `executing` + no PR is the one row that reads
451
+ // identically for a dead implementation and for a close that is halfway
452
+ // through its gate chain, and answering it from labels alone is what sent
453
+ // operators to re-init on top of a live close.
454
+ if (closeLooksLive(closeArtifacts)) {
455
+ return closeInFlightVerdict({
456
+ storyId,
457
+ artifacts: closeArtifacts,
458
+ evidence,
459
+ });
460
+ }
461
+ if (closeArtifacts?.envelope) {
462
+ return envelopeOnDiskVerdict({
463
+ storyId,
464
+ artifacts: closeArtifacts,
465
+ evidence,
466
+ });
467
+ }
245
468
  return {
246
469
  shape: 'executing-no-pr',
247
470
  nextCommand: NEXT_COMMANDS.implement(storyId),
248
471
  detail:
249
- `Story is \`agent::executing\` with no PR. Implementation never finished. Re-init ` +
250
- `(idempotent it reuses the existing branch and worktree) and resume in the ` +
472
+ `Story is \`agent::executing\` with no PR, and no close left an artifact behind (no ` +
473
+ `persisted terminal envelope, no recent gate log) implementation never finished. ` +
474
+ `Re-init (idempotent — it reuses the existing branch and worktree) and resume in the ` +
251
475
  `worktree it prints.`,
252
476
  evidence,
253
477
  };
@@ -280,6 +504,11 @@ const TRANSIENT_SHAPES = new Set([
280
504
  'closing-no-pr',
281
505
  'closing-pr-pending',
282
506
  'closing-pr-red',
507
+ // Story #4816 — the definition of this shape is "a process is mutating this
508
+ // Story right now", so it is the most transient row in the table: the second
509
+ // probe often catches the push and PR landing and returns a settled verdict
510
+ // instead.
511
+ 'close-in-flight',
283
512
  ]);
284
513
 
285
514
  /**
@@ -330,6 +559,7 @@ async function probeAndDecide({
330
559
  config,
331
560
  gh,
332
561
  gitSpawnFn,
562
+ fsImpl,
333
563
  }) {
334
564
  const ticket = await probeTicket({ provider, storyId });
335
565
  if (!ticket.ok) {
@@ -339,8 +569,22 @@ async function probeAndDecide({
339
569
  }
340
570
  const branch = probeBranch({ cwd, storyBranch, config, gitSpawnFn });
341
571
  const pr = await probePr({ storyBranch, gh });
342
- const decision = decideRecovery({ storyId, ticket, branch, pr });
343
- return { probes: { ticket, branch, pr }, decision };
572
+ // Re-read on every round: the whole point of the stability pass is that a
573
+ // second look can catch a close that has since flushed a gate line or
574
+ // written its envelope.
575
+ const closeArtifacts = probeCloseArtifacts({
576
+ storyId,
577
+ config,
578
+ ...(fsImpl ? { fsImpl } : {}),
579
+ });
580
+ const decision = decideRecovery({
581
+ storyId,
582
+ ticket,
583
+ branch,
584
+ pr,
585
+ closeArtifacts,
586
+ });
587
+ return { probes: { ticket, branch, pr, closeArtifacts }, decision };
344
588
  }
345
589
 
346
590
  /**
@@ -365,6 +609,7 @@ async function probeAndDecide({
365
609
  * settling).
366
610
  * @param {number} [args.stabilityDelayMs] Settle window between the probes.
367
611
  * @param {Function} [args.sleepFn] Test seam for the settle wait.
612
+ * @param {typeof nodeFs} [args.fsImpl] Test seam for the close-artifact reads.
368
613
  * @returns {Promise<object>}
369
614
  */
370
615
  export async function recoverStory({
@@ -374,6 +619,7 @@ export async function recoverStory({
374
619
  config,
375
620
  gh = defaultGh,
376
621
  gitSpawnFn,
622
+ fsImpl,
377
623
  reprobe = true,
378
624
  stabilityDelayMs = STABILITY_DELAY_MS,
379
625
  sleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -387,6 +633,7 @@ export async function recoverStory({
387
633
  config,
388
634
  gh,
389
635
  gitSpawnFn,
636
+ fsImpl,
390
637
  };
391
638
 
392
639
  const first = await probeAndDecide(probeArgs);
@@ -30,8 +30,10 @@
30
30
  * 2. **Over-scope stops, never silently proceeds ({@link
31
31
  * resolveLightGateOutcome}).** An over-ceiling prompt does **not**
32
32
  * hard-fail — it STOPS and asks the operator to escalate to `/plan` or
33
- * proceed light. Under `--yes` (unattended) it fails closed to
34
- * recommending `/plan`.
33
+ * proceed light. Both answers are executable: `proceed-light` is recorded
34
+ * through {@link resolveOperatorOverride}, which waives a *size
35
+ * prediction* only, never a risk rule, and only with a human present.
36
+ * Under `--yes` (unattended) it fails closed to recommending `/plan`.
35
37
  * 3. **Diff-derived backstop ({@link checkLightDiffBackstop}).** After
36
38
  * implementation the **actual** change set is re-checked with
37
39
  * {@link module:lib/orchestration/review-depth.deriveChangeLevel} plus a
@@ -48,9 +50,34 @@
48
50
  * @module lib/orchestration/light-suitability
49
51
  */
50
52
 
51
- import { deriveStoryShape, STORY_SHAPE_CEILINGS } from './complexity-gate.js';
53
+ import {
54
+ deriveStoryShape,
55
+ SHAPE_CODES,
56
+ STORY_SHAPE_CEILINGS,
57
+ } from './complexity-gate.js';
52
58
  import { deriveChangeLevel } from './review-depth.js';
53
59
 
60
+ /**
61
+ * The shape objections an attended operator may waive (Story #4815) — an
62
+ * **allowlist**, deliberately, so a rule added to
63
+ * {@link module:lib/orchestration/complexity-gate.SHAPE_CODES} later is
64
+ * non-negotiable until someone decides otherwise here. A denylist would make
65
+ * every new rule silently overridable.
66
+ *
67
+ * These four are the *size predictions*: coarse by design (§ Scope by effort),
68
+ * and already bounded for real by {@link checkLightDiffBackstop} against the
69
+ * landed diff. Everything absent is absent on purpose — `migration-span` and
70
+ * `sensitive-path` are **risk**, not size, and the unknown-footprint codes
71
+ * describe a shape that was never judged, so there is no false positive to
72
+ * appeal.
73
+ */
74
+ export const OVERRIDABLE_SHAPE_CODES = Object.freeze([
75
+ SHAPE_CODES.CHANGE_KINDS,
76
+ SHAPE_CODES.MAGNITUDE,
77
+ SHAPE_CODES.UNCERTAINTY,
78
+ SHAPE_CODES.DEPLOYABLE_SPAN,
79
+ ]);
80
+
54
81
  /**
55
82
  * File-count ceiling for the **actual landed** change set the diff backstop
56
83
  * ({@link checkLightDiffBackstop}) enforces. This is the light path's **only**
@@ -192,6 +219,88 @@ export function deriveLightSuitability({
192
219
  };
193
220
  }
194
221
 
222
+ /**
223
+ * Adjudicate an operator's recorded `proceed-light` answer to the gate's own
224
+ * question (Story #4815). The gate has always *offered* `proceed-light` as one
225
+ * of two options; before this there was no input that could carry the answer,
226
+ * so the only way past a coarse prediction was to re-shape the declaration
227
+ * until the gate stopped objecting — precisely the under-declaring the coarse
228
+ * design anticipates.
229
+ *
230
+ * Applying it takes **all** of:
231
+ *
232
+ * 1. **The gate actually objected.** An override cannot pre-authorize a
233
+ * scope nothing rejected.
234
+ * 2. **The run is attended.** `--yes` means nobody is at the keyboard, so
235
+ * there is no operator whose answer this could be (§ Escalation is
236
+ * terminal). Checked here as well as at the CLI, so the pure core carries
237
+ * the guarantee rather than the shell.
238
+ * 3. **The objection is a size prediction** — a code in
239
+ * {@link OVERRIDABLE_SHAPE_CODES}. Sensitivity and migration span are
240
+ * risk and stay absolute however small the change.
241
+ * 4. **The ledgered verdict is already `lite`.** The override substitutes for
242
+ * the *shape* half of the conjunction only; an unaudited "trust me, it's
243
+ * small" buys nothing it did not buy before.
244
+ *
245
+ * A refusal is reported, never silent — an operator who typed the flag must
246
+ * learn why it did not take. Pure and total.
247
+ *
248
+ * @param {{
249
+ * suitability?: object,
250
+ * yes?: boolean,
251
+ * operatorOverride?: unknown,
252
+ * }} [args] `operatorOverride` is the operator's recorded reason; blank or
253
+ * absent means no override was requested.
254
+ * @returns {{
255
+ * applied: boolean,
256
+ * record: { recordedReason: string, overriddenCode: string, overriddenReason: string }|null,
257
+ * note: string|null,
258
+ * }}
259
+ */
260
+ export function resolveOperatorOverride({
261
+ suitability,
262
+ yes = false,
263
+ operatorOverride,
264
+ } = {}) {
265
+ const recordedReason =
266
+ typeof operatorOverride === 'string' ? operatorOverride.trim() : '';
267
+ const refuse = (note) => ({ applied: false, record: null, note });
268
+
269
+ if (recordedReason === '') return refuse(null);
270
+ if (suitability?.suitable === true) {
271
+ return refuse(
272
+ 'operator override ignored — the gate raised no objection to override',
273
+ );
274
+ }
275
+ if (yes === true) {
276
+ return refuse(
277
+ 'operator override refused — it is attended-only, and --yes means nobody is at the keyboard; over-scope still fails closed to /plan',
278
+ );
279
+ }
280
+
281
+ const code = suitability?.shape?.code ?? null;
282
+ if (!OVERRIDABLE_SHAPE_CODES.includes(code)) {
283
+ return refuse(
284
+ `operator override refused — "${code ?? 'unknown'}" is not an overridable size prediction (overridable: ${OVERRIDABLE_SHAPE_CODES.join(', ')}); risk rules and unknown footprints are non-negotiable`,
285
+ );
286
+ }
287
+ if (suitability?.ledger?.route !== 'lite') {
288
+ return refuse(
289
+ 'operator override refused — it substitutes for the predicted shape only; the model verdict must still be a ledgered lite with a recorded reason',
290
+ );
291
+ }
292
+
293
+ return {
294
+ applied: true,
295
+ record: {
296
+ recordedReason,
297
+ overriddenCode: code,
298
+ overriddenReason: suitability?.shape?.reasons?.[0] ?? '',
299
+ },
300
+ note: `operator override applied — proceeding light despite "${code}"; recorded reason: ${recordedReason}. The diff backstop still bounds the actual change set.`,
301
+ };
302
+ }
303
+
195
304
  /**
196
305
  * Resolve what the light gate does with a suitability decision (Story #4740
197
306
  * AC-3). Over-scope never hard-fails: it STOPS and asks the operator to choose,
@@ -200,17 +309,43 @@ export function deriveLightSuitability({
200
309
  *
201
310
  * - suitable → `proceed-light`
202
311
  * - over-scope + attended (`yes:false`) → `ask-operator` (escalate | proceed)
312
+ * - over-scope + attended + an applied operator override (Story #4815)
313
+ * → `proceed-light`, carrying the
314
+ * decision in `override`
203
315
  * - over-scope + unattended (`yes:true`) → `escalate-plan`
204
316
  *
317
+ * The override is adjudicated by {@link resolveOperatorOverride} and cannot
318
+ * reach the unattended branch: `escalate-plan` is resolved first and the
319
+ * override refuses itself under `yes` anyway.
320
+ *
205
321
  * Pure and total.
206
322
  *
207
- * @param {{ suitability?: { suitable?: boolean, reasons?: string[] }, yes?: boolean }} [args]
208
- * @returns {{ action: 'proceed-light'|'ask-operator'|'escalate-plan', options?: string[], reasons: string[] }}
323
+ * @param {{
324
+ * suitability?: { suitable?: boolean, reasons?: string[] },
325
+ * yes?: boolean,
326
+ * operatorOverride?: unknown,
327
+ * }} [args]
328
+ * @returns {{
329
+ * action: 'proceed-light'|'ask-operator'|'escalate-plan',
330
+ * options?: string[],
331
+ * override?: object,
332
+ * reasons: string[],
333
+ * }}
209
334
  */
210
- export function resolveLightGateOutcome({ suitability, yes = false } = {}) {
335
+ export function resolveLightGateOutcome({
336
+ suitability,
337
+ yes = false,
338
+ operatorOverride,
339
+ } = {}) {
340
+ const override = resolveOperatorOverride({
341
+ suitability,
342
+ yes,
343
+ operatorOverride,
344
+ });
211
345
  const reasons = Array.isArray(suitability?.reasons)
212
346
  ? [...suitability.reasons]
213
347
  : [];
348
+ if (override.note !== null) reasons.push(override.note);
214
349
 
215
350
  if (suitability?.suitable === true) {
216
351
  return {
@@ -232,6 +367,17 @@ export function resolveLightGateOutcome({ suitability, yes = false } = {}) {
232
367
  };
233
368
  }
234
369
 
370
+ if (override.applied) {
371
+ return {
372
+ action: 'proceed-light',
373
+ override: override.record,
374
+ reasons: [
375
+ ...reasons,
376
+ 'predicted scope exceeded a light ceiling and the operator answered proceed-light — proceeding on the recorded override',
377
+ ],
378
+ };
379
+ }
380
+
235
381
  return {
236
382
  action: 'ask-operator',
237
383
  options: ['escalate-plan', 'proceed-light'],
@@ -417,18 +563,53 @@ function toReceiptChanges(changedFiles) {
417
563
  return entries;
418
564
  }
419
565
 
566
+ /**
567
+ * Render an applied operator override as an audit paragraph for the receipt
568
+ * body (Story #4815). An override that leaves no trace on the ticket is an
569
+ * invisible decision: the whole point of routing it through the receipt is
570
+ * that a later reader can see the gate objected, on what grounds, and who
571
+ * decided to proceed anyway.
572
+ *
573
+ * @param {unknown} override The `record` from {@link resolveOperatorOverride}.
574
+ * @returns {string} A leading-space-prefixed sentence, or `''` when absent.
575
+ */
576
+ function renderOverrideNote(override) {
577
+ if (!override || typeof override !== 'object') return '';
578
+ const { overriddenCode, overriddenReason, recordedReason } = override;
579
+ if (typeof recordedReason !== 'string' || recordedReason.trim() === '') {
580
+ return '';
581
+ }
582
+ return (
583
+ ` OPERATOR SCOPE OVERRIDE: the suitability gate objected on ` +
584
+ `"${overriddenCode}" (${overriddenReason}) and the operator answered ` +
585
+ `proceed-light — recorded reason: ${recordedReason.trim()}. The ` +
586
+ `prediction was waived, not the diff backstop, which still bounds the ` +
587
+ `landed change set.`
588
+ );
589
+ }
590
+
420
591
  /**
421
592
  * Build the minimal receipt `type::story` ticket for the light path
422
593
  * (Story #4740 AC-5) — the input `assemblePlanStories` / `createStoryIssues`
423
594
  * consume, so the light path reuses the plan-persist story-creation surface
424
595
  * rather than reimplementing issue authoring. The body carries the operator
425
- * prompt (goal + spec) and the diff-derived footprint (`changes[]`), so history
426
- * and `refs #<id>` on the commit survive.
596
+ * prompt (goal + spec), the diff-derived footprint (`changes[]`), and any
597
+ * operator scope override, so history and `refs #<id>` on the commit survive.
427
598
  *
428
- * @param {{ prompt?: unknown, changedFiles?: unknown, amends?: unknown }} [args]
599
+ * @param {{
600
+ * prompt?: unknown,
601
+ * changedFiles?: unknown,
602
+ * amends?: unknown,
603
+ * override?: unknown,
604
+ * }} [args]
429
605
  * @returns {{ slug: string, title: string, body: object, labels: string[] }}
430
606
  */
431
- export function buildReceiptStoryTicket({ prompt, changedFiles, amends } = {}) {
607
+ export function buildReceiptStoryTicket({
608
+ prompt,
609
+ changedFiles,
610
+ amends,
611
+ override,
612
+ } = {}) {
432
613
  const text = typeof prompt === 'string' ? prompt.trim() : '';
433
614
  if (text === '') {
434
615
  throw new Error(
@@ -438,6 +619,7 @@ export function buildReceiptStoryTicket({ prompt, changedFiles, amends } = {}) {
438
619
  const amendsId = normalizeAmends(amends);
439
620
  const amendNote = amendsId !== null ? ` Amends #${amendsId}.` : '';
440
621
  const changes = toReceiptChanges(changedFiles);
622
+ const overrideNote = renderOverrideNote(override);
441
623
 
442
624
  return {
443
625
  slug: slugifyPrompt(text),
@@ -448,7 +630,8 @@ export function buildReceiptStoryTicket({ prompt, changedFiles, amends } = {}) {
448
630
  spec:
449
631
  `Delivered via /deliver-light as a validated single-session change — ` +
450
632
  `the /plan session is removed for genuinely small work while every ` +
451
- `single-story-close gate runs byte-identical.${amendNote} ` +
633
+ `single-story-close gate runs byte-identical.${amendNote}` +
634
+ `${overrideNote} ` +
452
635
  `Operator prompt: ${text}`,
453
636
  changes,
454
637
  acceptance: [
@@ -317,21 +317,24 @@ export function buildStoriesEnvelope({
317
317
  const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
318
318
  return {
319
319
  kind: 'stories',
320
- // `dispatchMode` (Story #4722): the resolver derives the per-Story
321
- // execution mode from the fetched Story BODY's own shape (the shared
322
- // shape function in `complexity-gate.js`) so `/deliver` reads one field
323
- // `inline` (lite-shaped: no story-worker / acceptance-critic sub-agent
324
- // boots) or `subagent` (everything else, the conservative default). The
325
- // `route::lite` label is a human-visible hint only, never the control
326
- // signal: a lost label cannot misroute delivery. Model-side fan-out
327
- // only; close gates are untouched.
320
+ // `dispatchMode` (Story #4722, #4736, #4829): the resolver reports the
321
+ // per-Story execution mode so `/deliver` reads one field `inline` (run
322
+ // deliver-story in the router's own session: no story-worker /
323
+ // acceptance-critic sub-agent boots) or `subagent` (the conservative
324
+ // default). Model-side fan-out only; close gates are untouched.
328
325
  //
329
- // `storyCount` (Story #4736) carries the run's topology into that same
330
- // decision: a run resolving exactly ONE Story is inline whatever its
331
- // shape, because the isolation a sub-agent buys only matters against a
332
- // concurrently-dispatched sibling. It is the resolved set size not the
333
- // undelivered remainder so the mode a caller reads for a given `--ids`
334
- // list never changes as siblings land mid-run.
326
+ // `storyCount` is the premise that decides it, and it is this call site's
327
+ // load-bearing argument: `inline` names the router's ONE session, so it is
328
+ // granted only to a run resolving exactly ONE Story, which has no
329
+ // concurrent sibling to share that session with. Passing the resolved set
330
+ // size here is therefore what makes the envelope self-consistent with the
331
+ // ready set `stories-wave-tick.js` computes from the same `dag`: a set of
332
+ // more than one can never come back with a Story claiming the session
333
+ // (Story #4829 — it previously could, whenever the body was lite-shaped).
334
+ // It is the resolved set size, NOT the undelivered remainder, so the mode
335
+ // a caller reads for a given `--ids` list never changes as siblings land
336
+ // mid-run. The `route::lite` label is a human-visible hint only, never the
337
+ // control signal.
335
338
  stories: sorted.map(({ id, title, body, url, labels, state }) => ({
336
339
  id,
337
340
  title,