mandrel 2.59.0 → 2.60.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 (97) hide show
  1. package/.agents/README.md +11 -9
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +6 -6
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +8 -4
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +4 -5
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  13. package/.agents/schemas/agentrc.schema.json +6 -11
  14. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  15. package/.agents/scripts/README.md +11 -1
  16. package/.agents/scripts/acceptance-eval.js +25 -27
  17. package/.agents/scripts/ceremony-derive.js +15 -10
  18. package/.agents/scripts/check-context-budget.js +148 -228
  19. package/.agents/scripts/check-schema-references.js +5 -3
  20. package/.agents/scripts/check-workflow-citations.js +33 -147
  21. package/.agents/scripts/coverage-capture.js +7 -4
  22. package/.agents/scripts/deliver-light.js +41 -100
  23. package/.agents/scripts/deliver-run.js +631 -0
  24. package/.agents/scripts/file-ci-gap.js +59 -11
  25. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  26. package/.agents/scripts/lib/changed-files.js +30 -0
  27. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  28. package/.agents/scripts/lib/config/explain.js +1 -3
  29. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  30. package/.agents/scripts/lib/config-resolver.js +1 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  32. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  33. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  34. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  35. package/.agents/scripts/lib/doc-tiers.js +4 -2
  36. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  37. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  38. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  39. package/.agents/scripts/lib/gh-exec.js +160 -0
  40. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  41. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  43. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  45. package/.agents/scripts/lib/orchestration/plan-context.js +13 -25
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  47. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +76 -95
  48. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +35 -18
  49. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  50. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  51. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  52. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  53. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  57. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  58. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  59. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  60. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  61. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  62. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  63. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  64. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  65. package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -15
  66. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  67. package/.agents/scripts/merge-baseline.js +4 -5
  68. package/.agents/scripts/plan-context.js +117 -28
  69. package/.agents/scripts/plan-persist.js +79 -28
  70. package/.agents/scripts/plan-run-epilogue.js +11 -8
  71. package/.agents/scripts/pr-watch-with-update.js +9 -2
  72. package/.agents/scripts/run-verify.js +13 -6
  73. package/.agents/scripts/single-story-init.js +7 -57
  74. package/.agents/scripts/stories-wave-tick.js +160 -26
  75. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  76. package/.agents/skills/skills.index.json +2 -2
  77. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  78. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  79. package/.agents/workflows/helpers/code-review.md +4 -2
  80. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  81. package/.agents/workflows/helpers/deliver-light.md +92 -101
  82. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  83. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  84. package/.agents/workflows/helpers/deliver-story.md +17 -18
  85. package/.agents/workflows/helpers/plan-reference.md +65 -54
  86. package/.agents/workflows/mandrel-deliver.md +47 -31
  87. package/.agents/workflows/mandrel-plan.md +22 -21
  88. package/.agents/workflows/mandrel-update.md +36 -21
  89. package/docs/CHANGELOG.md +35 -0
  90. package/lib/cli/update.js +376 -17
  91. package/lib/migrations/index.js +2 -0
  92. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  93. package/package.json +2 -1
  94. package/.agents/schemas/model-attribution.schema.json +0 -53
  95. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  96. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  97. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
@@ -587,4 +587,164 @@ export function createGh(execImpl = exec, defaultExecOpts = {}) {
587
587
  */
588
588
  export const gh = createGh();
589
589
 
590
+ /* ---------------------------------------------------------------------- */
591
+ /* GraphQL reachability preflight (Story #5355) */
592
+ /* ---------------------------------------------------------------------- */
593
+
594
+ /**
595
+ * The `gh pr` subcommands the facade above reaches GitHub's pull-request
596
+ * surface through. `gh` routes **every one of them** through GitHub's
597
+ * GraphQL API, so they stand or fall together: where GraphQL answers HTTP
598
+ * 403 there is no PR operation left to attempt, not a degraded subset.
599
+ *
600
+ * Kept beside the `pr` facade it enumerates, and module-private, so the
601
+ * refusal text below cannot drift from the surface it claims to describe.
602
+ */
603
+ const GH_PR_SUBCOMMANDS = Object.freeze([
604
+ 'view',
605
+ 'create',
606
+ 'edit',
607
+ 'merge',
608
+ 'update-branch',
609
+ 'list',
610
+ ]);
611
+
612
+ /**
613
+ * The cheapest authenticated GraphQL read there is — "who am I". It touches
614
+ * no repository, needs no scope beyond the one `gh` already has, and costs a
615
+ * single API read, which is the whole budget the preflight is allowed.
616
+ */
617
+ const GRAPHQL_PROBE_QUERY = 'query{viewer{login}}';
618
+
619
+ /** Wall-clock ceiling for the probe. A hung probe must never hold a close. */
620
+ const GRAPHQL_PROBE_TIMEOUT_MS = 15_000;
621
+
622
+ /**
623
+ * Classify a failed probe into one of the two refusing verdicts, or back to
624
+ * `available` when the failure says nothing about GraphQL reachability.
625
+ *
626
+ * The fall-through is deliberate and it is **fail-open**: a timeout, a DNS
627
+ * blip or a novel `gh` error is not evidence that this session cannot reach
628
+ * GraphQL, and blocking a healthy close on an ambiguous probe would trade a
629
+ * rare late failure for a common early one. Such a probe reports `available`
630
+ * with `reason: 'probe-inconclusive'`, so the log still shows it happened.
631
+ *
632
+ * A **rate limit is that same fail-open case wearing a 403** (Story #5362).
633
+ * GitHub answers both its primary and its secondary rate limits with HTTP
634
+ * 403, and a throttled session says nothing about whether GraphQL is
635
+ * reachable from here — so the rate-limit test runs *before* the bare status
636
+ * match, and it accepts either shape the evidence arrives in: the typed
637
+ * {@link GhRateLimitError} the shared classifier already produces, or
638
+ * rate-limit text riding on an otherwise untyped 403. Classifying it
639
+ * `unavailable` would block the Story behind a remedy that cannot work —
640
+ * "re-run from a local session" is an instruction to go reproduce the same
641
+ * throttle. A 403 with no rate-limit evidence is still the web-session shape
642
+ * this preflight was built for and still refuses.
643
+ *
644
+ * @param {unknown} err The rejection `gh.api` produced.
645
+ * @returns {{ verdict: 'available'|'unavailable'|'auth-failed', reason: string }}
646
+ */
647
+ function classifyGraphqlProbeFailure(err) {
648
+ const haystack = `${err?.stderr ?? ''}\n${err?.message ?? ''}`.toLowerCase();
649
+ const authShaped =
650
+ err instanceof GhAuthError ||
651
+ err instanceof GhScopeError ||
652
+ /http 401|bad credentials|requires authentication|not logged into|gh auth login/.test(
653
+ haystack,
654
+ );
655
+ if (authShaped) return { verdict: 'auth-failed', reason: 'auth' };
656
+ const rateLimited =
657
+ err instanceof GhRateLimitError || /rate[ -]?limit/.test(haystack);
658
+ if (rateLimited)
659
+ return { verdict: 'available', reason: 'rate-limited-inconclusive' };
660
+ if (/http 403|403 forbidden/.test(haystack))
661
+ return { verdict: 'unavailable', reason: 'http-403' };
662
+ return { verdict: 'available', reason: 'probe-inconclusive' };
663
+ }
664
+
665
+ /**
666
+ * Probe whether GitHub's GraphQL API is reachable from this session.
667
+ *
668
+ * One `gh api graphql` read, three verdicts:
669
+ *
670
+ * - `available` — GraphQL answered (or the probe failed in a way that
671
+ * says nothing about reachability — an ambiguous error
672
+ * or a rate limit; see the fail-open note on
673
+ * {@link classifyGraphqlProbeFailure}).
674
+ * - `unavailable` — GraphQL answered HTTP 403. This is the Claude Code
675
+ * web-session shape: the token is fine, the endpoint
676
+ * is simply not reachable from here, so the entire
677
+ * `gh pr` surface is gated.
678
+ * - `auth-failed` — `gh` has no usable token (missing, expired or
679
+ * under-scoped). A different fault with a different
680
+ * remedy, deliberately NOT flattened into the one
681
+ * above: telling an unauthenticated operator to "run
682
+ * this somewhere else" sends them to reproduce it.
683
+ *
684
+ * Never throws — a preflight that can fail a close is worse than no
685
+ * preflight at all.
686
+ *
687
+ * @param {{ ghFacade?: { api: Function } }} [opts]
688
+ * `ghFacade` is the injection seam; production passes nothing.
689
+ * @returns {Promise<{ verdict: string, available: boolean, reason: string,
690
+ * detail: string|null }>}
691
+ */
692
+ export async function probeGraphqlAvailability({ ghFacade = gh } = {}) {
693
+ try {
694
+ await ghFacade.api({
695
+ method: 'POST',
696
+ endpoint: 'graphql',
697
+ body: { query: GRAPHQL_PROBE_QUERY },
698
+ execOpts: { timeoutMs: GRAPHQL_PROBE_TIMEOUT_MS },
699
+ });
700
+ return {
701
+ verdict: 'available',
702
+ available: true,
703
+ reason: 'ok',
704
+ detail: null,
705
+ };
706
+ } catch (err) {
707
+ const { verdict, reason } = classifyGraphqlProbeFailure(err);
708
+ return {
709
+ verdict,
710
+ available: verdict === 'available',
711
+ reason,
712
+ detail: describeGhFailure(err),
713
+ };
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Render a refusing probe verdict as the operator-facing blocker.
719
+ *
720
+ * The two refusals get two messages because they have two remedies. The
721
+ * unavailable text names all three things the operator needs — that GraphQL
722
+ * is unavailable *in this session*, the whole `gh pr` surface that gates, and
723
+ * that the fix is to run the close from a local session rather than to retry
724
+ * here.
725
+ *
726
+ * @param {{ verdict?: string, detail?: string|null }} probe
727
+ * @returns {string}
728
+ */
729
+ export function describeGraphqlPreflight({ verdict, detail } = {}) {
730
+ const suffix = detail ? ` (gh said: ${detail})` : '';
731
+ if (verdict === 'auth-failed') {
732
+ return (
733
+ 'GitHub authentication failed: `gh` has no usable token (missing, expired, or missing a ' +
734
+ 'required scope), so no GitHub call this close makes can succeed. This is NOT the ' +
735
+ 'GraphQL-unavailable condition and moving sessions will not fix it — re-authenticate ' +
736
+ '(`gh auth login`, or export a valid token) and re-run close where you are.' +
737
+ suffix
738
+ );
739
+ }
740
+ const subcommands = GH_PR_SUBCOMMANDS.map((s) => `\`gh pr ${s}\``).join(', ');
741
+ return (
742
+ 'GitHub GraphQL is unavailable in this session (HTTP 403). `gh` routes the entire pull-request ' +
743
+ `surface through GraphQL — ${subcommands} — so this close can neither open, inspect, nor merge ` +
744
+ 'a pull request, and every later phase would fail the same way. Retrying here will not help: ' +
745
+ 're-run the close from a local session, where GraphQL is reachable.' +
746
+ suffix
747
+ );
748
+ }
749
+
590
750
  export default exec;
@@ -112,6 +112,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
112
112
  'coverage-capture.js',
113
113
  'deliver-light.js',
114
114
  'deliver-recover.js',
115
+ 'deliver-run.js',
115
116
  'diagnose-friction.js',
116
117
  'diagnose.js',
117
118
  'drain-pending-cleanup.js',
@@ -1,84 +1,61 @@
1
1
  /**
2
- * lib/orchestration/ceremony-routing.js — ceremony-profile + derived-level
3
- * acceptance ceremony resolver.
4
- *
5
- * The sibling of `review-depth.js#resolveDepth`: it folds the operator ceremony
6
- * profile and the **derived** change level into a per-cluster ceremony decision
7
- * for the single-delivery acceptance critic — **fresh-context spawn** vs the
8
- * contract-identical **inline** critic. It does NOT invent a new risk score and
9
- * it does NOT own clustering.
10
- *
11
- * ## One derived source, two decisions (Story #4542)
12
- *
13
- * `derivedLevel` comes from `review-depth.js#deriveChangeLevel` — the same call
14
- * that feeds review depth — so both ceremony decisions read one observable
15
- * signal: does the change set touch a sensitive path registered in
16
- * `audit-rules.json`? Previously this consumed the planner's own risk verdict,
17
- * which meant a confident all-low self-assertion bought *less* independent
18
- * checking than authoring nothing at all. A derived level cannot be talked down.
19
- *
20
- * ## Ceremony profiles (`delivery.routing.ceremonyProfile`)
21
- *
22
- * - `minimal` — always `inline` (no fresh critic spawn).
23
- * Use for tiny N=1 Stories the operator trusts.
24
- * - `standard` — level-routed (default). Low → inline; high or
25
- * underivable → fresh.
26
- * - `strict` — always `fresh` regardless of the derived level.
27
- *
28
- * ## The load-bearing invariant (M4-B acceptance floor — DO NOT VIOLATE)
29
- *
30
- * Risk-routing chooses fresh-vs-inline **PER CLUSTER**. It NEVER changes the
31
- * cluster COUNT — the caller owns clustering and hands this module a cluster
32
- * index. A low-risk Story still gets one verdict per cluster — just possibly
33
- * authored inline instead of by a fresh sub-agent. This module takes the
34
- * cluster index as an INPUT and returns a decision for that one cluster; it
35
- * has no way to add or remove clusters.
36
- *
37
- * ## One verdict-owner per cluster (Story #4723)
38
- *
39
- * The resolved decision names the cluster's **single verdict owner** via
2
+ * lib/orchestration/ceremony-routing.js — ceremony-profile acceptance
3
+ * ceremony resolver.
4
+ *
5
+ * The sibling of `review-depth.js#resolveDepth`: it folds the operator
6
+ * ceremony profile into a per-Story ceremony decision for the acceptance
7
+ * verdict — **fresh-context spawn** vs the contract-identical **inline**
8
+ * self-eval. It does NOT invent a risk score, and it no longer routes off
9
+ * anything the diff says.
10
+ *
11
+ * ## The profile is the whole decision (Story #5343)
12
+ *
13
+ * - `minimal` — `inline`.
14
+ * - `standard` — `inline` (the **default**).
15
+ * - `strict` — `fresh`.
16
+ *
17
+ * A frontier-model worker scoring its own `acceptance[]` items against the
18
+ * `verify[]` output it just produced is the default the delivery diet settled
19
+ * on: the fresh critic's isolation bought a second full-context boot per
20
+ * Story and, measured across the run, changed no verdict the inline pass did
21
+ * not already reach. `strict` keeps the fresh-context critic for
22
+ * high-assurance surfaces, and is the one profile that spawns one.
23
+ *
24
+ * **The derived change level is not an input here (Story #5366).** It is
25
+ * still derived and still printed by `ceremony-derive.js`, because **review
26
+ * depth** reads it — `review-depth.js#resolveDepth` continues to resolve
27
+ * `deep` for any sensitive class, and that is untouched. What changed with
28
+ * #5343 is which of the two decisions the level feeds: review depth, not the
29
+ * verdict owner. Story #5366 finished the job by removing it (and the
30
+ * per-cluster index beside it) from this function's signature: a decision
31
+ * function that accepts what it ignores reads, to every caller and every
32
+ * reviewer, as though the input still mattered. An unenumerable diff is
33
+ * therefore not a fail-safe escalation here at all; it escalates review
34
+ * depth instead, where the evidence it withholds actually matters.
35
+ *
36
+ * ## One verdict-owner per Story (Story #4723, narrowed by #5343)
37
+ *
38
+ * The resolved decision names the Story's **single verdict owner** via
40
39
  * `verdictOwner`: `'fresh-critic'` when the mode is `fresh`,
41
40
  * `'inline-self-eval'` when the mode is `inline`. Exactly one pass authors
42
- * the cluster's verdict — the fresh maker-blind critic OR the
41
+ * the Story's verdict — the fresh maker-blind critic OR the
43
42
  * contract-identical inline self-eval, never both, and never an additional
44
43
  * pre-pass self-assessment before the owner runs. `acceptance-eval.js` is
45
44
  * the deterministic SCORER of that one authored verdict (schema validation,
46
45
  * round cap, proceed/redraft/block) — it is not a third pass over the
47
- * criteria. This removes a redundant pass only; it never removes a
48
- * cluster's verdict (the M4-B floor above holds).
49
- *
50
- * ## Tier rules (per cluster, `standard` profile)
51
- *
52
- * - `high` level → `fresh` (a sensitive path was touched — a
53
- * fresh-context maker-blind spawn).
54
- * - `low` level → `inline` (the contract-identical inline critic).
55
- * - missing / unknown → `fresh` (fail-safe: the diff could not be
56
- * enumerated, so there is no evidence the
57
- * change is unremarkable; treat it as
58
- * needing the full fresh-context ceremony,
59
- * exactly as `resolveDepth` degrades to
60
- * `standard` on the same signal).
61
- *
62
- * ## No sampling floor (Story #5313)
63
- *
64
- * The maker-checker sampling floor (`delivery.routing.freshCriticSampleRate`,
65
- * `sampledFresh`) bounded independent checking by a cluster-index stride —
66
- * a count, not a risk signal — and was retired with the delivery diet. The
67
- * standard profile now routes purely off the derived level, so the decision
68
- * carries no `sampled` field and `clusterIndex` is accepted only for call-site
69
- * compatibility (it never changes the outcome).
46
+ * criteria. The verdict is **one file per Story**, scored in one gate call;
47
+ * the cluster protocol that used to split it was retired with #5343.
70
48
  *
71
49
  * Pure and total: inputs in, decision out. No I/O, no throws. `null` /
72
- * `undefined` / malformed inputs degrade to `fresh` + `full` ceremony.
50
+ * `undefined` / malformed inputs degrade to the default profile.
73
51
  *
74
52
  * @typedef {'fresh'|'inline'} CeremonyMode
75
53
  * @typedef {'fresh-critic'|'inline-self-eval'} VerdictOwner
76
- * @typedef {import('./review-depth.js').ChangeLevel} ChangeLevel
77
54
  * @typedef {(typeof CEREMONY_PROFILES)[number]} CeremonyProfile
78
55
  */
79
56
 
80
57
  /**
81
- * Map a resolved ceremony mode to the cluster's single verdict owner
58
+ * Map a resolved ceremony mode to the Story's single verdict owner
82
59
  * (Story #4723). Total: any non-`fresh` value maps to the inline
83
60
  * self-eval owner, mirroring how the mode itself degrades.
84
61
  *
@@ -102,6 +79,28 @@ const CEREMONY_PROFILES = Object.freeze(['minimal', 'standard', 'strict']);
102
79
  /** The profile an absent or unrecognized value degrades to. */
103
80
  const DEFAULT_CEREMONY_PROFILE = 'standard';
104
81
 
82
+ /**
83
+ * The one routing table: profile → mode + the reason the decision carries.
84
+ * `strict` is the only profile that spawns a fresh critic.
85
+ *
86
+ * @type {Readonly<Record<CeremonyProfile, { mode: CeremonyMode, reason: string }>>}
87
+ */
88
+ const PROFILE_DECISIONS = Object.freeze({
89
+ minimal: {
90
+ mode: 'inline',
91
+ reason: 'ceremonyProfile=minimal: inline self-eval (no fresh spawn)',
92
+ },
93
+ standard: {
94
+ mode: 'inline',
95
+ reason:
96
+ 'ceremonyProfile=standard: inline self-eval authors the Story verdict',
97
+ },
98
+ strict: {
99
+ mode: 'fresh',
100
+ reason: 'ceremonyProfile=strict: fresh-context critic',
101
+ },
102
+ });
103
+
105
104
  /**
106
105
  * Normalize an operator/config ceremony profile. Unknown values degrade to
107
106
  * `standard` (fail toward the documented default, not toward less ceremony).
@@ -116,16 +115,13 @@ function normalizeCeremonyProfile(value) {
116
115
  }
117
116
 
118
117
  /**
119
- * Resolve the acceptance ceremony for one cluster from the ceremony profile
120
- * and the derived change level. See the module header for the tier rules and
121
- * the untouchable cluster-count invariant.
118
+ * Resolve the acceptance ceremony for one Story from the ceremony profile —
119
+ * its one and only input. See the module header for the profile table and why
120
+ * the derived change level is not among them.
122
121
  *
123
122
  * @param {{
124
- * derivedLevel?: (ChangeLevel|string|null|undefined),
125
- * clusterIndex?: (number|null|undefined),
126
123
  * ceremonyProfile?: (CeremonyProfile|string|null|undefined),
127
- * }} [input] `clusterIndex` is accepted for call-site compatibility only
128
- * (Story #5313 retired the sampling floor that read it).
124
+ * }} [input]
129
125
  * @returns {{
130
126
  * mode: CeremonyMode,
131
127
  * reason: string,
@@ -134,64 +130,10 @@ function normalizeCeremonyProfile(value) {
134
130
  * }}
135
131
  */
136
132
  export function resolveCeremonyForRisk(input = {}) {
137
- const decision = resolveCeremonyDecision(input);
138
- return { ...decision, verdictOwner: verdictOwnerForMode(decision.mode) };
139
- }
140
-
141
- /**
142
- * Internal mode/reason resolution — the tier rules. `resolveCeremonyForRisk`
143
- * decorates the result with the single `verdictOwner` derived from the mode
144
- * (Story #4723).
145
- *
146
- * @param {Parameters<typeof resolveCeremonyForRisk>[0]} [input]
147
- * @returns {{
148
- * mode: CeremonyMode,
149
- * reason: string,
150
- * profile: CeremonyProfile,
151
- * }}
152
- */
153
- function resolveCeremonyDecision(input = {}) {
154
- const derivedLevel =
155
- input && typeof input === 'object' ? input.derivedLevel : undefined;
156
- const profile = normalizeCeremonyProfile(
157
- input && typeof input === 'object' ? input.ceremonyProfile : undefined,
158
- );
159
-
160
- if (profile === 'minimal') {
161
- return {
162
- mode: 'inline',
163
- reason: 'ceremonyProfile=minimal: inline critic (no fresh spawn)',
164
- profile,
165
- };
166
- }
167
- if (profile === 'strict') {
168
- return {
169
- mode: 'fresh',
170
- reason: 'ceremonyProfile=strict: fresh-context critic',
171
- profile,
172
- };
173
- }
174
-
175
- if (derivedLevel === 'high') {
176
- return {
177
- mode: 'fresh',
178
- reason: 'sensitive path touched: fresh-context critic',
179
- profile,
180
- };
181
- }
182
- if (derivedLevel === 'low') {
183
- return {
184
- mode: 'inline',
185
- reason: 'no sensitive path touched: contract-identical inline critic',
186
- profile,
187
- };
188
- }
189
- // Missing / unknown / malformed level → fail-safe fresh + full ceremony,
190
- // matching how resolveDepth degrades to `standard` on the same signal.
191
- return {
192
- mode: 'fresh',
193
- reason:
194
- 'change level underivable: fail-safe fresh-context critic + full ceremony',
195
- profile,
196
- };
133
+ // Optional chaining rather than a typeof guard: `normalizeCeremonyProfile`
134
+ // is already total over anything that is not one of the three names, so a
135
+ // non-object input degrades to `standard` through the same door.
136
+ const profile = normalizeCeremonyProfile(input?.ceremonyProfile);
137
+ const { mode, reason } = PROFILE_DECISIONS[profile];
138
+ return { mode, reason, profile, verdictOwner: verdictOwnerForMode(mode) };
197
139
  }
@@ -24,6 +24,19 @@
24
24
  * (`file-ci-gap.js`, carrying the run link and failure signature the digest
25
25
  * already holds) is required before it can proceed.
26
26
  *
27
+ * **One rerun after a recorded verdict (Story #5343).** The single exception,
28
+ * and it is evidence-gated rather than discretionary: once `file-ci-gap.js`
29
+ * has filed a `capacity` or `unreproducible-tier` verdict **for the current
30
+ * head SHA**, it stamps a `rerunAllowance` on the digest, and exactly one
31
+ * same-SHA green is then admitted — the digest is retired and the delivery
32
+ * proceeds. This closes the one shape where the rule stranded a correct
33
+ * delivery: a runner that ran out of something has no fix at source to make,
34
+ * so the branch could never move its head SHA and the block needed a human to
35
+ * clear. Every other same-SHA green is still a violation, the allowance is
36
+ * spent the moment it is honoured (it dies with the digest), and a second red
37
+ * after the rerun is real: it writes a fresh digest with no allowance, so it
38
+ * routes to Option 1 like any other red.
39
+ *
27
40
  * **Fail closed on an unverifiable green.** A digest whose head SHA is
28
41
  * missing, or a current head SHA that `gh` could not resolve, leaves no
29
42
  * evidence that the red was fixed at source. An unresolved red plus no
@@ -53,6 +66,20 @@ import {
53
66
  /** How many superseded unresolved reds a digest carries before the oldest is dropped. */
54
67
  const MAX_PRIOR_REDS = 10;
55
68
 
69
+ /**
70
+ * The verdicts that earn the one same-SHA rerun (Story #5343). Both mean the
71
+ * failure is a property of the *environment*, proven — so no commit on the
72
+ * branch can move the head SHA to clear it. `pre-existing` is deliberately
73
+ * excluded: it reproduces on `main`, which is a real defect someone owns and
74
+ * a rerun cannot make go away.
75
+ *
76
+ * @type {readonly ['capacity', 'unreproducible-tier']}
77
+ */
78
+ export const RERUN_ALLOWANCE_VERDICTS = Object.freeze([
79
+ 'capacity',
80
+ 'unreproducible-tier',
81
+ ]);
82
+
56
83
  /**
57
84
  * Resolve which ticket the digest is keyed to. Story #4539: the digest used
58
85
  * to be Epic-scoped by filename and returned `null` without an epic id — so
@@ -332,7 +359,9 @@ function renderDigestMarkdown(digest, failures) {
332
359
  '',
333
360
  'A green on THIS head SHA is a re-run of a failed job and is forbidden',
334
361
  '(`.agents/rules/ci-remediation.md` § Verifier). Fix at source and push a',
335
- 'new commit — the head SHA moving is what clears this digest.',
362
+ 'new commit — the head SHA moving is what clears this digest. The one',
363
+ 'exception: `file-ci-gap.js --verdict capacity|unreproducible-tier` records',
364
+ 'an allowance for this head SHA, and exactly one rerun is then admitted.',
336
365
  '',
337
366
  '## `gh run view --log-failed` tail',
338
367
  '',
@@ -408,16 +437,89 @@ export function writeCiDigest({
408
437
  return { jsonPath: paths.jsonPath, mdPath: paths.mdPath };
409
438
  }
410
439
 
440
+ /**
441
+ * Stamp the one-rerun allowance on the scope's digest (Story #5343).
442
+ *
443
+ * Called by `file-ci-gap.js` after it has filed the intake issue, so the
444
+ * allowance exists only where the evidence the filing carries does. It is
445
+ * keyed on the digest's own head SHA — the head the red was recorded
446
+ * against — so an allowance can never be honoured on a later head it was not
447
+ * earned for.
448
+ *
449
+ * Best-effort and non-throwing: a digest that cannot be re-read or re-written
450
+ * simply records no allowance, and the guard keeps blocking. Returns the
451
+ * recorded allowance, or `null` when none was written (no digest, an
452
+ * ineligible verdict, or an unresolved head SHA).
453
+ *
454
+ * @param {{
455
+ * storyId?: number|string|null,
456
+ * verdict: string,
457
+ * tempRoot: string,
458
+ * cwd: string,
459
+ * now?: () => Date,
460
+ * }} opts
461
+ * @returns {{ verdict: string, headSha: string, recordedAt: string } | null}
462
+ */
463
+ export function recordRerunAllowance({
464
+ storyId = null,
465
+ verdict,
466
+ tempRoot,
467
+ cwd,
468
+ now = () => new Date(),
469
+ }) {
470
+ if (!RERUN_ALLOWANCE_VERDICTS.includes(verdict)) return null;
471
+ const paths = ciDigestPaths({ storyId, tempRoot, cwd });
472
+ const digest = readCiDigest({ storyId, tempRoot, cwd });
473
+ if (!paths || !digest?.headSha) return null;
474
+ const allowance = {
475
+ verdict,
476
+ headSha: digest.headSha,
477
+ recordedAt: now().toISOString(),
478
+ };
479
+ try {
480
+ writeFileSync(
481
+ paths.jsonPath,
482
+ `${JSON.stringify({ ...digest, rerunAllowance: allowance }, null, 2)}\n`,
483
+ );
484
+ } catch {
485
+ return null;
486
+ }
487
+ return allowance;
488
+ }
489
+
490
+ /**
491
+ * The allowance a digest carries for `headSha`, or `null`. An allowance
492
+ * recorded against a different head is not one: the red it was filed for is
493
+ * not the red being adjudicated.
494
+ *
495
+ * @param {object|null} digest
496
+ * @param {string|null} headSha
497
+ * @returns {{ verdict: string, headSha: string } | null}
498
+ */
499
+ function allowanceFor(digest, headSha) {
500
+ const allowance = digest?.rerunAllowance;
501
+ return allowance &&
502
+ typeof allowance === 'object' &&
503
+ headSha &&
504
+ allowance.headSha === headSha &&
505
+ RERUN_ALLOWANCE_VERDICTS.includes(allowance.verdict)
506
+ ? allowance
507
+ : null;
508
+ }
509
+
411
510
  /**
412
511
  * Adjudicate an all-green watch against any digest recorded for the scope.
413
512
  *
414
513
  * @param {{ digest: object|null, headSha: string|null }} opts
415
- * @returns {{ verdict: 'clean'|'fix-at-source'|'rerun'|'unverifiable', reason: string }}
416
- * - `clean` — no digest: this delivery never went red.
417
- * - `fix-at-source` — the head SHA moved since the red; legal.
418
- * - `rerun` — green on the SAME head SHA; forbidden.
419
- * - `unverifiable` — an unresolved red with no head-SHA evidence either
420
- * side; fail closed and treat it as a rerun.
514
+ * @returns {{ verdict: 'clean'|'fix-at-source'|'rerun-permitted'|'rerun'|'unverifiable', reason: string }}
515
+ * - `clean` — no digest: this delivery never went red.
516
+ * - `fix-at-source` — the head SHA moved since the red; legal.
517
+ * - `rerun-permitted` — same head SHA, but `file-ci-gap.js` recorded a
518
+ * `capacity` / `unreproducible-tier` verdict for it;
519
+ * the one sanctioned rerun (Story #5343).
520
+ * - `rerun` — green on the SAME head SHA; forbidden.
521
+ * - `unverifiable` — an unresolved red with no head-SHA evidence either
522
+ * side; fail closed and treat it as a rerun.
421
523
  */
422
524
  export function classifyGreenVerdict({ digest, headSha }) {
423
525
  if (!digest) return { verdict: 'clean', reason: 'no digest for this scope' };
@@ -429,10 +531,16 @@ export function classifyGreenVerdict({ digest, headSha }) {
429
531
  };
430
532
  }
431
533
  if (recorded === headSha) {
432
- return {
433
- verdict: 'rerun',
434
- reason: `green on the SAME head SHA the red was recorded against (${headSha})`,
435
- };
534
+ const allowance = allowanceFor(digest, headSha);
535
+ return allowance
536
+ ? {
537
+ verdict: 'rerun-permitted',
538
+ reason: `one rerun admitted: \`${allowance.verdict}\` verdict recorded for this head SHA (${headSha})`,
539
+ }
540
+ : {
541
+ verdict: 'rerun',
542
+ reason: `green on the SAME head SHA the red was recorded against (${headSha})`,
543
+ };
436
544
  }
437
545
  return {
438
546
  verdict: 'fix-at-source',
@@ -487,7 +595,10 @@ export function formatRerunViolation({ digest, headSha, prNumber, reason }) {
487
595
  ' <consumer|framework|platform> --evidence "<proof reading>"` — then',
488
596
  ' resume. It carries the run link and signature above, routes the filing',
489
597
  ' to whoever owns the fault, and updates the existing ticket when this',
490
- ' signature has been seen before.',
598
+ ' signature has been seen before. A `capacity` or `unreproducible-tier`',
599
+ ' verdict also records the one-rerun allowance for this head SHA, so a',
600
+ ' single rerun of the failed job is then admitted; `pre-existing` does',
601
+ ' not, because it names a real defect a rerun cannot remove.',
491
602
  ].join('\n');
492
603
  }
493
604