mandrel 2.58.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.
- package/.agents/README.md +17 -12
- package/.agents/agents/acceptance-critic.md +24 -43
- package/.agents/agents/story-worker.md +18 -19
- package/.agents/docs/SDLC.md +12 -13
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +29 -46
- package/.agents/docs/quality-gates.md +9 -5
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +5 -7
- package/.agents/rules/ci-remediation.md +41 -8
- package/.agents/rules/known-tooling-behavior.md +65 -15
- package/.agents/runtime-deps.json +7 -2
- package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -11
- package/.agents/schemas/crap-baseline.schema.json +1 -1
- package/.agents/schemas/crap-report.schema.json +1 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
- package/.agents/scripts/README.md +11 -1
- package/.agents/scripts/acceptance-eval.js +25 -27
- package/.agents/scripts/ceremony-derive.js +15 -10
- package/.agents/scripts/check-context-budget.js +148 -228
- package/.agents/scripts/check-schema-references.js +5 -3
- package/.agents/scripts/check-workflow-citations.js +33 -147
- package/.agents/scripts/coverage-capture.js +7 -4
- package/.agents/scripts/deliver-light.js +41 -100
- package/.agents/scripts/deliver-run.js +631 -0
- package/.agents/scripts/file-ci-gap.js +59 -11
- package/.agents/scripts/install-matrix-assert.js +48 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
- package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
- package/.agents/scripts/lib/changed-files.js +30 -0
- package/.agents/scripts/lib/config/delivery-routing.js +5 -4
- package/.agents/scripts/lib/config/explain.js +1 -3
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
- package/.agents/scripts/lib/config-resolver.js +1 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
- package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
- package/.agents/scripts/lib/crap-engine.js +2 -2
- package/.agents/scripts/lib/crap-utils.js +21 -5
- package/.agents/scripts/lib/doc-tiers.js +4 -2
- package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
- package/.agents/scripts/lib/escomplex-kernel.js +298 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/gh-exec.js +160 -0
- package/.agents/scripts/lib/maintainability-engine.js +3 -3
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
- package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
- package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
- package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
- package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
- package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
- package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
- package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
- package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
- package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
- package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
- package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
- package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
- package/.agents/scripts/lib/story-body/story-body.js +83 -29
- package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
- package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
- package/.agents/scripts/merge-baseline.js +4 -5
- package/.agents/scripts/plan-context.js +117 -28
- package/.agents/scripts/plan-persist.js +79 -39
- package/.agents/scripts/plan-run-epilogue.js +11 -8
- package/.agents/scripts/pr-watch-with-update.js +9 -2
- package/.agents/scripts/run-verify.js +13 -6
- package/.agents/scripts/single-story-init.js +7 -57
- package/.agents/scripts/stories-wave-tick.js +160 -26
- package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
- package/.agents/skills/skills.index.json +2 -12
- package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
- package/.agents/workflows/audit-to-stories.md +14 -11
- package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
- package/.agents/workflows/helpers/code-review.md +4 -2
- package/.agents/workflows/helpers/deliver-digest.md +31 -24
- package/.agents/workflows/helpers/deliver-light.md +92 -101
- package/.agents/workflows/helpers/deliver-reference.md +116 -100
- package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
- package/.agents/workflows/helpers/deliver-story.md +17 -18
- package/.agents/workflows/helpers/plan-reference.md +82 -60
- package/.agents/workflows/mandrel-deliver.md +47 -31
- package/.agents/workflows/mandrel-plan.md +32 -30
- package/.agents/workflows/mandrel-update.md +36 -21
- package/README.md +3 -3
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/registry.js +45 -25
- package/lib/cli/update.js +376 -17
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
- package/package.json +8 -2
- package/.agents/schemas/model-attribution.schema.json +0 -53
- package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
- package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
- package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
- package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
- package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
- package/.agents/skills/core/scope-triage/SKILL.md +0 -48
|
@@ -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;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import escomplex from 'typhonjs-escomplex';
|
|
3
2
|
import { install as installAstCompat } from './escomplex-ast-compat.js';
|
|
3
|
+
import { analyzeModule } from './escomplex-kernel.js';
|
|
4
4
|
import { transpileIfNeeded } from './transpile.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -45,7 +45,7 @@ const UNSCORABLE = 0;
|
|
|
45
45
|
*/
|
|
46
46
|
export function scoreSource(sourceCode) {
|
|
47
47
|
try {
|
|
48
|
-
const score =
|
|
48
|
+
const score = analyzeModule(sourceCode)?.maintainability;
|
|
49
49
|
return Number.isFinite(score)
|
|
50
50
|
? { score, unscorable: false, reason: null }
|
|
51
51
|
: unscorable(`kernel returned a non-finite index (${String(score)})`);
|
|
@@ -141,7 +141,7 @@ export function scoreFile(filePath) {
|
|
|
141
141
|
*/
|
|
142
142
|
export function calculateReport(sourceCode) {
|
|
143
143
|
try {
|
|
144
|
-
const result =
|
|
144
|
+
const result = analyzeModule(sourceCode);
|
|
145
145
|
const methods = (result.methods ?? []).map((m) => ({
|
|
146
146
|
name: m.name,
|
|
147
147
|
maintainability:
|
|
@@ -1,84 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/orchestration/ceremony-routing.js — ceremony-profile
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* The sibling of `review-depth.js#resolveDepth`: it folds the operator
|
|
6
|
-
* profile
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* ##
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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.
|
|
48
|
-
* cluster
|
|
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
|
|
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
|
|
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
|
|
120
|
-
*
|
|
121
|
-
* the
|
|
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]
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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`
|
|
417
|
-
* - `fix-at-source`
|
|
418
|
-
* - `rerun`
|
|
419
|
-
*
|
|
420
|
-
*
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|