mandrel 2.8.0 → 2.10.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/.markdownlint.json +4 -0
- package/.agents/agents/acceptance-critic.md +30 -5
- package/.agents/agents/auditor.md +36 -19
- package/.agents/agents/plan-critic.md +31 -5
- package/.agents/agents/story-worker.md +91 -100
- package/.agents/docs/configuration.md +39 -1
- package/.agents/docs/execution-reference.md +13 -0
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +131 -265
- package/.agents/rules/git-conventions.md +47 -83
- package/.agents/rules/orchestration-error-handling.md +28 -0
- package/.agents/schemas/agentrc.schema.json +44 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +1 -1
- package/.agents/scripts/apply-quality-bootstrap.js +1 -1
- package/.agents/scripts/audit-to-stories.js +51 -0
- package/.agents/scripts/check-test-temp-hygiene.js +438 -0
- package/.agents/scripts/deliver-recover.js +23 -6
- package/.agents/scripts/lib/audit-suite/index.js +5 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
- package/.agents/scripts/lib/config/temp-paths.js +121 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
- package/.agents/scripts/lib/config-settings-schema.js +32 -0
- package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
- package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
- package/.agents/scripts/lib/observability/terse-result.js +114 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
- package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
- package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +60 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
- package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
- package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
- package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
- package/.agents/scripts/lib/story-body/story-body.js +18 -2
- package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
- package/.agents/scripts/lib/test-env.js +65 -0
- package/.agents/scripts/plan-context.js +66 -9
- package/.agents/scripts/plan-critics.js +115 -3
- package/.agents/scripts/plan-persist.js +11 -1
- package/.agents/scripts/plan-run-epilogue.js +1 -1
- package/.agents/scripts/providers/github/issues.js +54 -7
- package/.agents/scripts/providers/github/search-budget.js +124 -0
- package/.agents/scripts/providers/github/search-query.js +71 -0
- package/.agents/scripts/single-story-confirm-merge.js +79 -10
- package/.agents/scripts/single-story-init.js +19 -3
- package/.agents/scripts/stories-wave-tick.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +9 -3
- package/.agents/workflows/deliver.md +86 -230
- package/.agents/workflows/helpers/deliver-reference.md +167 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
- package/.agents/workflows/helpers/deliver-story.md +114 -422
- package/.agents/workflows/helpers/plan-reference.md +211 -0
- package/.agents/workflows/plan.md +107 -279
- package/docs/CHANGELOG.md +47 -0
- package/package.json +1 -1
|
@@ -263,9 +263,96 @@ export function decideRecovery({ storyId, ticket, branch, pr }) {
|
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The shapes a LIVE delivery process actively mutates while it runs. A probe
|
|
268
|
+
* that lands mid-close can read `executing` + `pr=none` seconds before the
|
|
269
|
+
* push and PR-open land, and confidently misdirect the operator to re-init a
|
|
270
|
+
* Story whose close is about to open a PR (observed live on Story #4712: two
|
|
271
|
+
* probes seconds apart flipped `executing-no-pr` → `executing-with-pr`).
|
|
272
|
+
* These shapes therefore earn a stability re-probe before the verdict is
|
|
273
|
+
* trusted; the remaining shapes (`merged-label-stale`, `blocked`,
|
|
274
|
+
* `done-board-drift`, `ready`) describe settled states no live process is
|
|
275
|
+
* racing to change.
|
|
276
|
+
*/
|
|
277
|
+
const TRANSIENT_SHAPES = new Set([
|
|
278
|
+
'executing-no-pr',
|
|
279
|
+
'executing-with-pr',
|
|
280
|
+
'closing-no-pr',
|
|
281
|
+
'closing-pr-pending',
|
|
282
|
+
'closing-pr-red',
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Default settle window between the two probes of the stability pass. Long
|
|
287
|
+
* enough for an in-flight push / `gh pr create` / label flip to land (each is
|
|
288
|
+
* a single network call), short enough that the read-only CLI stays
|
|
289
|
+
* interactive.
|
|
290
|
+
*/
|
|
291
|
+
const STABILITY_DELAY_MS = 5000;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Build the verdict for a state observed mid-mutation: the two probe rounds
|
|
295
|
+
* derived DIFFERENT shapes, so neither is safe to act on — acting on the
|
|
296
|
+
* first misdirects (the #4712 shape), acting on the second may race the same
|
|
297
|
+
* live process again. The one next command is the probe itself, re-run once
|
|
298
|
+
* the live process settles.
|
|
299
|
+
*
|
|
300
|
+
* @param {{ storyId: number, first: object, second: object, delayMs: number }} args
|
|
301
|
+
* @returns {{ shape: string, nextCommand: string, detail: string, evidence: string[] }}
|
|
302
|
+
*/
|
|
303
|
+
function buildInTransitionVerdict({ storyId, first, second, delayMs }) {
|
|
304
|
+
return {
|
|
305
|
+
shape: 'in-transition',
|
|
306
|
+
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
307
|
+
detail:
|
|
308
|
+
`Two probes ${Math.round(delayMs / 1000)}s apart derived different shapes ` +
|
|
309
|
+
`(\`${first.shape}\` → \`${second.shape}\`): a delivery process is actively ` +
|
|
310
|
+
`mutating this Story's state right now (a push, PR open, or label flip landed ` +
|
|
311
|
+
`between the probes). Acting on either verdict risks duplicating or misdirecting ` +
|
|
312
|
+
`the live run. Wait for it to finish, then re-run this probe for a settled verdict.`,
|
|
313
|
+
evidence: [
|
|
314
|
+
`probe1.shape=${first.shape}`,
|
|
315
|
+
`probe2.shape=${second.shape}`,
|
|
316
|
+
...second.evidence,
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* One full probe round: ticket + branch + PR → decision. Throws only when
|
|
323
|
+
* the ticket itself is unreadable (the probe cannot run without it).
|
|
324
|
+
*/
|
|
325
|
+
async function probeAndDecide({
|
|
326
|
+
storyId,
|
|
327
|
+
storyBranch,
|
|
328
|
+
cwd,
|
|
329
|
+
provider,
|
|
330
|
+
config,
|
|
331
|
+
gh,
|
|
332
|
+
gitSpawnFn,
|
|
333
|
+
}) {
|
|
334
|
+
const ticket = await probeTicket({ provider, storyId });
|
|
335
|
+
if (!ticket.ok) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`deliver-recover: could not read Story #${storyId}: ${ticket.error}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
const branch = probeBranch({ cwd, storyBranch, config, gitSpawnFn });
|
|
341
|
+
const pr = await probePr({ storyBranch, gh });
|
|
342
|
+
const decision = decideRecovery({ storyId, ticket, branch, pr });
|
|
343
|
+
return { probes: { ticket, branch, pr }, decision };
|
|
344
|
+
}
|
|
345
|
+
|
|
266
346
|
/**
|
|
267
347
|
* Probe live state and resolve the single next command. Read-only.
|
|
268
348
|
*
|
|
349
|
+
* Transient shapes (`executing-*` / `closing-*`) get a **stability re-probe**
|
|
350
|
+
* (same consecutive-evidence pattern the merge wait's fail-fast uses, Story
|
|
351
|
+
* #4695): a second probe after a short settle window. Matching shapes return
|
|
352
|
+
* the fresher verdict; diverging shapes return `in-transition` instead of a
|
|
353
|
+
* confidently wrong command. Settled shapes skip the second round — their
|
|
354
|
+
* state has no live process racing to change it.
|
|
355
|
+
*
|
|
269
356
|
* @param {object} args
|
|
270
357
|
* @param {number} args.storyId
|
|
271
358
|
* @param {string} args.cwd
|
|
@@ -273,6 +360,11 @@ export function decideRecovery({ storyId, ticket, branch, pr }) {
|
|
|
273
360
|
* @param {object} [args.config]
|
|
274
361
|
* @param {object} [args.gh]
|
|
275
362
|
* @param {Function} [args.gitSpawnFn]
|
|
363
|
+
* @param {boolean} [args.reprobe=true] Disable to skip the stability pass
|
|
364
|
+
* (single-probe legacy behavior — for scripted callers that own their own
|
|
365
|
+
* settling).
|
|
366
|
+
* @param {number} [args.stabilityDelayMs] Settle window between the probes.
|
|
367
|
+
* @param {Function} [args.sleepFn] Test seam for the settle wait.
|
|
276
368
|
* @returns {Promise<object>}
|
|
277
369
|
*/
|
|
278
370
|
export async function recoverStory({
|
|
@@ -282,22 +374,57 @@ export async function recoverStory({
|
|
|
282
374
|
config,
|
|
283
375
|
gh = defaultGh,
|
|
284
376
|
gitSpawnFn,
|
|
377
|
+
reprobe = true,
|
|
378
|
+
stabilityDelayMs = STABILITY_DELAY_MS,
|
|
379
|
+
sleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
285
380
|
}) {
|
|
286
381
|
const storyBranch = getStoryBranch(storyId);
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
382
|
+
const probeArgs = {
|
|
383
|
+
storyId,
|
|
384
|
+
storyBranch,
|
|
385
|
+
cwd,
|
|
386
|
+
provider,
|
|
387
|
+
config,
|
|
388
|
+
gh,
|
|
389
|
+
gitSpawnFn,
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const first = await probeAndDecide(probeArgs);
|
|
393
|
+
if (!reprobe || !TRANSIENT_SHAPES.has(first.decision.shape)) {
|
|
394
|
+
return {
|
|
395
|
+
storyId,
|
|
396
|
+
storyBranch,
|
|
397
|
+
probes: first.probes,
|
|
398
|
+
stability: { reprobed: false },
|
|
399
|
+
...first.decision,
|
|
400
|
+
};
|
|
292
401
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
402
|
+
|
|
403
|
+
await sleepFn(stabilityDelayMs);
|
|
404
|
+
const second = await probeAndDecide(probeArgs);
|
|
405
|
+
|
|
406
|
+
if (second.decision.shape === first.decision.shape) {
|
|
407
|
+
// Stable across the settle window — trust the fresher evidence.
|
|
408
|
+
return {
|
|
409
|
+
storyId,
|
|
410
|
+
storyBranch,
|
|
411
|
+
probes: second.probes,
|
|
412
|
+
stability: { reprobed: true, stable: true, delayMs: stabilityDelayMs },
|
|
413
|
+
...second.decision,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
296
417
|
return {
|
|
297
418
|
storyId,
|
|
298
419
|
storyBranch,
|
|
299
|
-
probes:
|
|
300
|
-
|
|
420
|
+
probes: second.probes,
|
|
421
|
+
stability: { reprobed: true, stable: false, delayMs: stabilityDelayMs },
|
|
422
|
+
...buildInTransitionVerdict({
|
|
423
|
+
storyId,
|
|
424
|
+
first: first.decision,
|
|
425
|
+
second: second.decision,
|
|
426
|
+
delayMs: stabilityDelayMs,
|
|
427
|
+
}),
|
|
301
428
|
};
|
|
302
429
|
}
|
|
303
430
|
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
* (`emit-merge-unlanded.js`).
|
|
72
72
|
*/
|
|
73
73
|
|
|
74
|
-
import {
|
|
74
|
+
import { requiredCheckFailedBlocksMerge } from './merge-poll.js';
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* Every class `classifyMergeBlock` can return. Order is the evaluation
|
|
@@ -169,12 +169,21 @@ function describeApiRaceFallback(prProbe, budget) {
|
|
|
169
169
|
* rejection surfaced AT arm time still routes to
|
|
170
170
|
* `branch-protection-human-required` rather than the generic
|
|
171
171
|
* `arm-failure`.
|
|
172
|
-
* 1b. A red required check — `checks-failed` (Story #4543
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* protected branch it also
|
|
176
|
-
* so leaving it to step 3
|
|
177
|
-
* to branch protection.
|
|
172
|
+
* 1b. A genuinely red required check — `checks-failed` (Story #4543,
|
|
173
|
+
* head-anchored by Story #4695). Evaluated before every budget and
|
|
174
|
+
* probe signal because it is *definitive*: no amount of remaining
|
|
175
|
+
* budget turns a failed check green, and on a protected branch it also
|
|
176
|
+
* presents as `mergeStateStatus: 'BLOCKED'`, so leaving it to step 3
|
|
177
|
+
* would attribute the operator's red test run to branch protection.
|
|
178
|
+
* Gated on `requiredCheckFailedBlocksMerge`: a run must have concluded
|
|
179
|
+
* failure with none in flight — a red rollup while a required run is
|
|
180
|
+
* merely queued is the protected-branch pending state, not this class.
|
|
181
|
+
* The predicate also declines the verdict when the probe reports
|
|
182
|
+
* `reviewDecision: 'REVIEW_REQUIRED'` (Story #4710): the rollup cannot
|
|
183
|
+
* prove the red run is required, and a missing required review already
|
|
184
|
+
* explains the BLOCKED merge state, so classification falls through to
|
|
185
|
+
* the step-3 human-required verdict instead of misdirecting the
|
|
186
|
+
* operator at a possibly-optional red check.
|
|
178
187
|
* 2. Budget exhaustion while checks were still in flight —
|
|
179
188
|
* `checks-pending-timeout`. Evaluated BEFORE the human-required
|
|
180
189
|
* probe signals because on a protected branch GitHub reports
|
|
@@ -241,23 +250,35 @@ export function classifyMergeBlock(input) {
|
|
|
241
250
|
// `undefined` (no probe at all) keeps its budget-timeout mapping in
|
|
242
251
|
// step 2 without suppressing the step-3 human-required verdict.
|
|
243
252
|
const checksStatus = prProbe?.checksStatus;
|
|
253
|
+
// A required run still queued/in-progress on the head is the
|
|
254
|
+
// protected-branch pending steady state (Story #4695), so it counts as
|
|
255
|
+
// in-flight evidence exactly like a `pending`/`still-running` aggregate —
|
|
256
|
+
// keeping a red-rollup-but-required-run-in-flight probe out of the
|
|
257
|
+
// human-required verdict below and into the timeout branch on budget expiry.
|
|
244
258
|
const checksPendingEvidence =
|
|
245
|
-
checksStatus === 'pending' ||
|
|
259
|
+
checksStatus === 'pending' ||
|
|
260
|
+
checksStatus === 'still-running' ||
|
|
261
|
+
prProbe?.requiredRunEvidence?.requiredRunInFlight === true;
|
|
246
262
|
|
|
247
263
|
// 1b. A required check is RED. Definitive — no remaining budget makes a
|
|
248
264
|
// failed check pass — so this precedes both the budget branch and the
|
|
249
265
|
// BLOCKED-merge-state heuristic, which would otherwise attribute the red
|
|
250
266
|
// check to branch protection on any protected base.
|
|
251
267
|
//
|
|
252
|
-
// Gated on `
|
|
253
|
-
// `checksStatus: 'failure'` covers optional checks
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
268
|
+
// Gated on `requiredCheckFailedBlocksMerge` (Story #4695), not the raw
|
|
269
|
+
// rollup status: `checksStatus: 'failure'` covers optional checks AND the
|
|
270
|
+
// protected-branch pending state where a required run is merely queued (the
|
|
271
|
+
// rollup counts a cancelled superseded run as failure). Naming either as THE
|
|
272
|
+
// block sends the operator to fix a check that was never gating the merge —
|
|
273
|
+
// on a PR that merges on its own. Only head-anchored evidence of a genuinely
|
|
274
|
+
// red required run with none in flight classifies here; anything short of
|
|
275
|
+
// that falls through, keeps polling, and — on budget expiry with checks in
|
|
276
|
+
// flight — classifies `checks-pending-timeout` as before.
|
|
277
|
+
if (requiredCheckFailedBlocksMerge(prProbe)) {
|
|
278
|
+
const evidencePath = prProbe?.evidencePath;
|
|
258
279
|
return {
|
|
259
280
|
blockClass: 'checks-failed',
|
|
260
|
-
reason: `a required check failed (mergeStateStatus=${prProbe?.mergeStateStatus ?? 'n/a'})`,
|
|
281
|
+
reason: `a required check failed (mergeStateStatus=${prProbe?.mergeStateStatus ?? 'n/a'}${evidencePath ? `, evidence=${evidencePath}` : ''})`,
|
|
261
282
|
};
|
|
262
283
|
}
|
|
263
284
|
|
|
@@ -27,6 +27,20 @@
|
|
|
27
27
|
export const DEFAULT_INTERVAL_SECONDS = 30;
|
|
28
28
|
export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Wall-clock bound for every `gh` subprocess the merge wait spawns (Story
|
|
32
|
+
* #4710). The wait is now routinely unattended (`delivery.mergeWatch.mode:
|
|
33
|
+
* "async"` runs it in a background invocation with no host tool ceiling), so
|
|
34
|
+
* a hung `gh pr view` / `gh pr update-branch` used to strand the wait with no
|
|
35
|
+
* terminal envelope, no label flip, and no friction record. Sixty seconds is
|
|
36
|
+
* generous for a single API round-trip while staying inside the async probe
|
|
37
|
+
* window; a timeout maps to the existing probe-error path, so the wait
|
|
38
|
+
* degrades to conservative-pending / `api-race-other` semantics instead of
|
|
39
|
+
* hanging. A framework constant by design — not config (Story #4710
|
|
40
|
+
* Non-Goals).
|
|
41
|
+
*/
|
|
42
|
+
export const MERGE_WAIT_GH_TIMEOUT_MS = 60_000;
|
|
43
|
+
|
|
30
44
|
/**
|
|
31
45
|
* Pure: derive an aggregate `checksStatus` (`success` | `still-running` |
|
|
32
46
|
* `failure` | `unknown`) from a `statusCheckRollup` array (`gh pr view --json
|
|
@@ -57,6 +71,80 @@ export function deriveChecksStatus(statusCheckRollup) {
|
|
|
57
71
|
return anyPending ? 'still-running' : 'success';
|
|
58
72
|
}
|
|
59
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Pure: derive HEAD-ANCHORED per-run evidence from a `statusCheckRollup`
|
|
76
|
+
* array, distinguishing a genuinely red required run from the pending /
|
|
77
|
+
* superseded noise the aggregate {@link deriveChecksStatus} folds together.
|
|
78
|
+
*
|
|
79
|
+
* {@link deriveChecksStatus} returns `failure` the instant it sees ANY
|
|
80
|
+
* non-passing conclusion — including a `CANCELLED` superseded-push run or a
|
|
81
|
+
* sibling-invalidated run — even while the real required check is still
|
|
82
|
+
* queued. Paired with `mergeStateStatus: BLOCKED` (the protected-branch steady
|
|
83
|
+
* state while required checks run), that matched a merely *pending* PR and
|
|
84
|
+
* hard-blocked Stories whose PRs merged untouched. This derivation reads the
|
|
85
|
+
* two signals the fail-fast decision actually needs:
|
|
86
|
+
*
|
|
87
|
+
* - `requiredRunFailed` — a run on the head concluded `FAILURE` (or a
|
|
88
|
+
* legacy status context is `FAILURE`/`ERROR`).
|
|
89
|
+
* Deliberately NOT `CANCELLED`/`TIMED_OUT`/
|
|
90
|
+
* `SKIPPED`: those are the superseded-push and
|
|
91
|
+
* sibling-invalidated runs, not a red required
|
|
92
|
+
* check.
|
|
93
|
+
* - `requiredRunInFlight` — any run on the head is still QUEUED /
|
|
94
|
+
* IN_PROGRESS (a CheckRun whose status is not
|
|
95
|
+
* `COMPLETED`, or a legacy status context still
|
|
96
|
+
* `PENDING`/`EXPECTED`).
|
|
97
|
+
*
|
|
98
|
+
* Returns `null` when the rollup is absent or empty — the evidence is
|
|
99
|
+
* unavailable and the caller must fall back to the consecutive-probe path
|
|
100
|
+
* (a single evidence-free failing snapshot must never fail-fast).
|
|
101
|
+
*
|
|
102
|
+
* **Contract honesty (Story #4710).** The `requiredRun*` field names describe
|
|
103
|
+
* what the evidence is USED to establish, not what this function reads: the
|
|
104
|
+
* `gh pr view` rollup projection carries no `isRequired` discriminator, so
|
|
105
|
+
* this derivation reads EVERY run on the head, required or not. On its own,
|
|
106
|
+
* `requiredRunFailed: true` therefore means "a head run genuinely concluded
|
|
107
|
+
* failure", and required-ness attribution is supplied downstream by
|
|
108
|
+
* {@link requiredCheckFailedBlocksMerge}, which admits the verdict only when
|
|
109
|
+
* `mergeStateStatus: BLOCKED` says GitHub itself gates the merge AND no
|
|
110
|
+
* review-required signal offers a competing explanation for that BLOCKED
|
|
111
|
+
* state. Do not treat this function's output as a required-only reading.
|
|
112
|
+
*
|
|
113
|
+
* @param {Array<{status?: string, conclusion?: string, state?: string}>} statusCheckRollup
|
|
114
|
+
* @returns {{ requiredRunFailed: boolean, requiredRunInFlight: boolean } | null}
|
|
115
|
+
*/
|
|
116
|
+
export function deriveRequiredRunEvidence(statusCheckRollup) {
|
|
117
|
+
if (!Array.isArray(statusCheckRollup) || statusCheckRollup.length === 0) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
let requiredRunFailed = false;
|
|
121
|
+
let requiredRunInFlight = false;
|
|
122
|
+
for (const check of statusCheckRollup) {
|
|
123
|
+
const conclusion = String(check?.conclusion ?? '').toUpperCase();
|
|
124
|
+
const status = String(check?.status ?? '').toUpperCase();
|
|
125
|
+
const state = String(check?.state ?? '').toUpperCase();
|
|
126
|
+
// In flight: a CheckRun not yet COMPLETED, or a legacy StatusContext still
|
|
127
|
+
// PENDING/EXPECTED. `status` is empty on a StatusContext, so it degrades to
|
|
128
|
+
// the `state` branch rather than counting as in-flight.
|
|
129
|
+
if (status && status !== 'COMPLETED') {
|
|
130
|
+
requiredRunInFlight = true;
|
|
131
|
+
} else if (state === 'PENDING' || state === 'EXPECTED') {
|
|
132
|
+
requiredRunInFlight = true;
|
|
133
|
+
}
|
|
134
|
+
// Genuinely red: FAILURE / ERROR only. CANCELLED / TIMED_OUT / SKIPPED are
|
|
135
|
+
// the superseded / sibling-invalidated noise a bare rollup miscounts.
|
|
136
|
+
if (
|
|
137
|
+
conclusion === 'FAILURE' ||
|
|
138
|
+
conclusion === 'ERROR' ||
|
|
139
|
+
state === 'FAILURE' ||
|
|
140
|
+
state === 'ERROR'
|
|
141
|
+
) {
|
|
142
|
+
requiredRunFailed = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { requiredRunFailed, requiredRunInFlight };
|
|
146
|
+
}
|
|
147
|
+
|
|
60
148
|
/**
|
|
61
149
|
* The one `mergeStateStatus` value that means GitHub itself is gating the
|
|
62
150
|
* merge. See {@link failingChecksBlockMerge}.
|
|
@@ -102,3 +190,128 @@ export function failingChecksBlockMerge(prProbe) {
|
|
|
102
190
|
String(prProbe?.mergeStateStatus ?? '').toUpperCase() === MERGE_GATED_STATE
|
|
103
191
|
);
|
|
104
192
|
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Pure: does HEAD-ANCHORED evidence establish that a REQUIRED check is
|
|
196
|
+
* genuinely red — enough to fail-fast the merge wait as `checks-failed`?
|
|
197
|
+
*
|
|
198
|
+
* This is the single gated decision Story #4695 adds, and the named predicate
|
|
199
|
+
* a downstream async-confirm Story imports rather than reopening the poll
|
|
200
|
+
* loop's classification internals. It layers on {@link failingChecksBlockMerge}
|
|
201
|
+
* (the rollup-`failure` + `mergeStateStatus: BLOCKED` gate) the head-anchored
|
|
202
|
+
* refinement the raw gate lacked: classify `checks-failed` ONLY when a run
|
|
203
|
+
* genuinely concluded failure AND none is still in flight. A red rollup while
|
|
204
|
+
* a required run is queued/in-progress is the protected-branch pending steady
|
|
205
|
+
* state, not a failure.
|
|
206
|
+
*
|
|
207
|
+
* The evidence is read from `prProbe.requiredRunEvidence` (the
|
|
208
|
+
* {@link deriveRequiredRunEvidence} output threaded through the probe). When it
|
|
209
|
+
* is absent — older `gh`, an API error, or a probe that never carried a rollup
|
|
210
|
+
* — this returns `false`: the caller's consecutive-probe fallback owns that
|
|
211
|
+
* path, because a single evidence-free failing snapshot must never fail-fast.
|
|
212
|
+
*
|
|
213
|
+
* **Review-required softening (Story #4710).** The rollup evidence cannot
|
|
214
|
+
* prove the red run is a REQUIRED check (see
|
|
215
|
+
* {@link deriveRequiredRunEvidence}), so when the probe carries a competing
|
|
216
|
+
* explanation for the `BLOCKED` merge state — `reviewDecision:
|
|
217
|
+
* 'REVIEW_REQUIRED'`, i.e. a required approval is missing — this predicate
|
|
218
|
+
* declines the `checks-failed` verdict. A red *optional* check beside a
|
|
219
|
+
* missing required review used to fail-fast as `checks-failed` and send the
|
|
220
|
+
* operator to fix a check that was never gating the merge; with the review
|
|
221
|
+
* signal present, classification falls through to the
|
|
222
|
+
* `branch-protection-human-required` branch, which names the gate GitHub
|
|
223
|
+
* actually attributes. When a genuinely red required check coexists with a
|
|
224
|
+
* missing review, both are true blocks and the human-required verdict is
|
|
225
|
+
* still an honest one — the conservative direction (see
|
|
226
|
+
* {@link failingChecksBlockMerge} on why failing to fail fast is the cheap
|
|
227
|
+
* error).
|
|
228
|
+
*
|
|
229
|
+
* @param {{ checksStatus?: string, mergeStateStatus?: string,
|
|
230
|
+
* reviewDecision?: string,
|
|
231
|
+
* requiredRunEvidence?: { requiredRunFailed?: boolean, requiredRunInFlight?: boolean } }} [prProbe]
|
|
232
|
+
* @returns {boolean}
|
|
233
|
+
*/
|
|
234
|
+
export function requiredCheckFailedBlocksMerge(prProbe) {
|
|
235
|
+
if (!failingChecksBlockMerge(prProbe)) return false;
|
|
236
|
+
if (prProbe?.reviewDecision === 'REVIEW_REQUIRED') return false;
|
|
237
|
+
const evidence = prProbe?.requiredRunEvidence;
|
|
238
|
+
if (!evidence || typeof evidence.requiredRunFailed !== 'boolean') {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
return (
|
|
242
|
+
evidence.requiredRunFailed === true && evidence.requiredRunInFlight !== true
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Pure: the merge wait's single fail-fast decision (Story #4710 — extracted
|
|
248
|
+
* from the two near-verbatim inline blocks in `runConfirmMergePhase`'s poll
|
|
249
|
+
* loop, beside its sibling predicates).
|
|
250
|
+
*
|
|
251
|
+
* Encapsulates the Story #4695 evidence policy in one place:
|
|
252
|
+
*
|
|
253
|
+
* - **Per-run evidence available** — decide on this single probe via
|
|
254
|
+
* {@link requiredCheckFailedBlocksMerge}; a required run still in flight
|
|
255
|
+
* (or only superseded / non-required noise red) resets the counter and
|
|
256
|
+
* keeps polling.
|
|
257
|
+
* - **Evidence unavailable** (older `gh`, API error, empty rollup) — require
|
|
258
|
+
* TWO consecutive failing probes at least one poll interval apart, then
|
|
259
|
+
* synthesize the evidence shape the classifier's gate reads so both paths
|
|
260
|
+
* classify `checks-failed` through the same predicate.
|
|
261
|
+
*
|
|
262
|
+
* Returns the next counter value alongside the verdict; the caller owns the
|
|
263
|
+
* mutable counter and the terminal side effects. When `failFast` is `true`,
|
|
264
|
+
* `prProbe` is the evidence-stamped probe to hand to the classifier and
|
|
265
|
+
* `evidencePath` names which path fired (`per-run` | `consecutive-probe`) for
|
|
266
|
+
* the `merge.unlanded` telemetry.
|
|
267
|
+
*
|
|
268
|
+
* @param {object} args
|
|
269
|
+
* @param {object} args.probe The current poll's {@code readPrWaitProbe} result.
|
|
270
|
+
* @param {number} args.consecutiveRequiredFailSnapshots Evidence-free failing
|
|
271
|
+
* probes observed so far.
|
|
272
|
+
* @returns {{ failFast: boolean, consecutiveRequiredFailSnapshots: number,
|
|
273
|
+
* prProbe?: object, evidencePath?: 'per-run'|'consecutive-probe' }}
|
|
274
|
+
*/
|
|
275
|
+
export function decideMergeWaitFailFast({
|
|
276
|
+
probe,
|
|
277
|
+
consecutiveRequiredFailSnapshots,
|
|
278
|
+
}) {
|
|
279
|
+
if (!failingChecksBlockMerge(probe)) {
|
|
280
|
+
return { failFast: false, consecutiveRequiredFailSnapshots: 0 };
|
|
281
|
+
}
|
|
282
|
+
if (probe?.requiredRunEvidence) {
|
|
283
|
+
if (requiredCheckFailedBlocksMerge(probe)) {
|
|
284
|
+
return {
|
|
285
|
+
failFast: true,
|
|
286
|
+
consecutiveRequiredFailSnapshots: 0,
|
|
287
|
+
evidencePath: 'per-run',
|
|
288
|
+
prProbe: { ...probe, evidencePath: 'per-run' },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// A required run is still in flight, only non-required / superseded runs
|
|
292
|
+
// are red, or a missing required review owns the BLOCKED state: the
|
|
293
|
+
// protected-branch steady state, not a failure. Keep polling.
|
|
294
|
+
return { failFast: false, consecutiveRequiredFailSnapshots: 0 };
|
|
295
|
+
}
|
|
296
|
+
const next = consecutiveRequiredFailSnapshots + 1;
|
|
297
|
+
// Synthesize the evidence the classifier's gate reads, so the
|
|
298
|
+
// consecutive-probe path classifies `checks-failed` through the SAME
|
|
299
|
+
// predicate as the per-run path (including its review-required softening).
|
|
300
|
+
const synthesized = {
|
|
301
|
+
...probe,
|
|
302
|
+
requiredRunEvidence: {
|
|
303
|
+
requiredRunFailed: true,
|
|
304
|
+
requiredRunInFlight: false,
|
|
305
|
+
},
|
|
306
|
+
evidencePath: 'consecutive-probe',
|
|
307
|
+
};
|
|
308
|
+
if (next >= 2 && requiredCheckFailedBlocksMerge(synthesized)) {
|
|
309
|
+
return {
|
|
310
|
+
failFast: true,
|
|
311
|
+
consecutiveRequiredFailSnapshots: next,
|
|
312
|
+
evidencePath: 'consecutive-probe',
|
|
313
|
+
prProbe: synthesized,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
return { failFast: false, consecutiveRequiredFailSnapshots: next };
|
|
317
|
+
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
renderTechSpecSystemPrompt,
|
|
27
27
|
} from '../templates/spec-author-prompts.js';
|
|
28
28
|
import { concurrentMap } from '../util/concurrent-map.js';
|
|
29
|
+
import { buildComplexityRouteSignal } from './complexity-gate.js';
|
|
29
30
|
import { parseDeliverySlicingTable } from './consolidation-precondition.js';
|
|
30
31
|
import { buildDocsDigest } from './docs-digest.js';
|
|
31
32
|
import { buildAuthoringContext } from './planning/authoring-context.js';
|
|
@@ -137,6 +138,63 @@ export const TICKET_SCHEMA_DESCRIPTOR = Object.freeze({
|
|
|
137
138
|
'validateAndNormalizeTickets (lib/orchestration/ticket-validator.js) at persist time',
|
|
138
139
|
});
|
|
139
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Filename of the ready-to-fill Story authoring template `plan-context.js`
|
|
143
|
+
* writes next to the captured envelope (Story #4707).
|
|
144
|
+
*/
|
|
145
|
+
export const STORIES_TEMPLATE_FILENAME = 'stories.template.json';
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render the ready-to-fill `stories.json` authoring template (Story #4707).
|
|
149
|
+
*
|
|
150
|
+
* One-shot authoring: the planner copies this file to `stories.json`, fills
|
|
151
|
+
* the placeholder values, and runs persist — no step requires reading
|
|
152
|
+
* `story-body.js` source or re-poking the envelope for format discovery
|
|
153
|
+
* (bench: ~7 of 17 plan turns were format discovery). The template uses the
|
|
154
|
+
* **structured-object body** shape, which persist parses and serializes to
|
|
155
|
+
* the canonical markdown itself (`parse` accepts an object;
|
|
156
|
+
* `assembleOnePlanStory` re-serializes canonically), so the serializer
|
|
157
|
+
* contract never has to be reverse-engineered by the author. `acceptance[]`
|
|
158
|
+
* / `verify[]` live at the ticket's top level — the machine contract persist
|
|
159
|
+
* syncs into the body.
|
|
160
|
+
*
|
|
161
|
+
* Pure and deterministic; the output is valid JSON (parseable as-is), with
|
|
162
|
+
* instructive placeholder values rather than comments.
|
|
163
|
+
*
|
|
164
|
+
* @returns {string} Pretty-printed JSON template content.
|
|
165
|
+
*/
|
|
166
|
+
export function renderStoriesTemplate() {
|
|
167
|
+
const template = [
|
|
168
|
+
{
|
|
169
|
+
slug: 'fill-hyphen-case-slug',
|
|
170
|
+
type: 'story',
|
|
171
|
+
title: 'Fill: short descriptive title',
|
|
172
|
+
body: {
|
|
173
|
+
goal: 'Fill: one sentence stating why this Story exists.',
|
|
174
|
+
spec:
|
|
175
|
+
'Optional — contract and invariants only: interfaces, status ' +
|
|
176
|
+
'codes, security invariants, and load-bearing constraints with ' +
|
|
177
|
+
'their why. Implementation choices belong to the deliverer unless ' +
|
|
178
|
+
'load-bearing. No per-file behavior paragraphs, no current-state ' +
|
|
179
|
+
'narration. Delete this field when acceptance[] carries the whole ' +
|
|
180
|
+
'contract.',
|
|
181
|
+
changes: [
|
|
182
|
+
{ path: 'path/to/file.ext', assumption: 'refactors-existing' },
|
|
183
|
+
],
|
|
184
|
+
non_goals: [],
|
|
185
|
+
reason_to_exist:
|
|
186
|
+
'Fill: the single coherent reason this Story exists (one sentence).',
|
|
187
|
+
},
|
|
188
|
+
acceptance: [
|
|
189
|
+
'Fill: a testable, observable criterion (a command exits 0, a file exists, a test matches)',
|
|
190
|
+
],
|
|
191
|
+
verify: ['Fill: exact command or test path (unit|contract|e2e|validate)'],
|
|
192
|
+
depends_on: [],
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
return `${JSON.stringify(template, null, 2)}\n`;
|
|
196
|
+
}
|
|
197
|
+
|
|
140
198
|
/**
|
|
141
199
|
* Count top-level enumerated items (`- `, `* `, `1. `) anywhere in a
|
|
142
200
|
* free-form seed text. Unlike {@link countScopeItems} this does not require
|
|
@@ -482,6 +540,7 @@ async function buildSeedFileModeEnvelope({
|
|
|
482
540
|
return {
|
|
483
541
|
mode: modeLabel,
|
|
484
542
|
seed: { path: seedFilePath ?? null, content },
|
|
543
|
+
complexityRoute: buildComplexityRouteSignal({ seedText: content, config }),
|
|
485
544
|
duplicates,
|
|
486
545
|
docsContext,
|
|
487
546
|
codebaseSnapshot: authoring.codebaseSnapshot,
|
|
@@ -635,6 +694,7 @@ async function buildTicketsModeEnvelope({
|
|
|
635
694
|
mode: 'tickets',
|
|
636
695
|
sourceTickets,
|
|
637
696
|
seed: { text: seed, path: null },
|
|
697
|
+
complexityRoute: buildComplexityRouteSignal({ seedText: seed, config }),
|
|
638
698
|
duplicates,
|
|
639
699
|
docsContext,
|
|
640
700
|
codebaseSnapshot: authoring.codebaseSnapshot,
|