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.
- package/.agents/README.md +1 -1
- package/.agents/agents/story-worker.md +15 -0
- package/.agents/instructions.md +14 -17
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/rules/known-tooling-behavior.md +114 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/deliver-light.js +72 -8
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +113 -7
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
- package/.agents/scripts/lib/label-constants.js +12 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
- package/.agents/scripts/lib/observability/signals-writer.js +133 -14
- package/.agents/scripts/lib/observability/source-classifier.js +131 -1
- package/.agents/scripts/lib/orchestration/code-review.js +12 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
- 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/resolve-stories.js +17 -14
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
- package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +6 -8
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
- package/.agents/workflows/helpers/deliver-story.md +8 -5
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +28 -0
- 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 {{
|
|
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({
|
|
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
|
|
250
|
-
`
|
|
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
|
-
|
|
343
|
-
|
|
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.
|
|
34
|
-
*
|
|
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 {
|
|
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 {{
|
|
208
|
-
*
|
|
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({
|
|
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)
|
|
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 {{
|
|
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({
|
|
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
|
|
321
|
-
// execution mode
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
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`
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
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,
|
|
Binary file
|