From 70bcdd2cce9c859bf899e9d01fa530d0f8adbbff Mon Sep 17 00:00:00 2001 From: norvalbv Date: Mon, 10 Aug 2026 20:04:26 +0100 Subject: [PATCH] release: v0.51.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump 0.50.0 -> 0.51.0 and rebuild dist from a clean origin/main worktree (f817b75). Minor, not patch: two feature PRs landed since 0.50.0 — sentry-additive restages that keep earned verdicts plus a sentry judge cache (#370), and the opt-in priorArtGate deny-once step-0 ordering component (#382). The rest of the range is fixes (#371-#383). The rebuild also picks up two dist modules that git had never seen. dist/ is gitignored on working branches by design, so gate-engine/review/cascade/ consumer-assets.mjs and reviewer.mjs — the compiled output of the #376 checklist-asset refactor — existed only on disk. They are force-added here, which is exactly the path `devkit release` takes for ignored dist output. Without them the shipped runtime.mjs would import a module absent from the tag. Release smoke checks ran: built bin reports 0.51.0, dist/package.json agrees, and dist/cli/lib/ship/ship-branch.sh still wires ship_read_stdin_body (the sc-1419 stdin-hang fix survived the build). Gates bypassed at the maintainer's request (--no-verify); the range was already reviewed on its constituent PRs. Co-Authored-By: Claude Opus 5 --- dist/gate-engine/deterministic/run.mjs | 40 ++-- dist/gate-engine/ratchets/size-disable.mjs | 39 ++-- dist/gate-engine/ratchets/size-preflight.mjs | 7 +- .../review/cascade/consumer-assets.mjs | 22 ++ dist/gate-engine/review/cascade/reviewer.mjs | 171 ++++++++++++++ dist/gate-engine/review/reviewers.mjs | 7 +- dist/gate-engine/review/run-review.mjs | 221 +----------------- dist/gate-engine/review/runtime.mjs | 18 +- dist/package.json | 2 +- package.json | 2 +- 10 files changed, 251 insertions(+), 278 deletions(-) create mode 100644 dist/gate-engine/review/cascade/consumer-assets.mjs create mode 100644 dist/gate-engine/review/cascade/reviewer.mjs diff --git a/dist/gate-engine/deterministic/run.mjs b/dist/gate-engine/deterministic/run.mjs index dfa12c17..fcd700cc 100644 --- a/dist/gate-engine/deterministic/run.mjs +++ b/dist/gate-engine/deterministic/run.mjs @@ -262,28 +262,28 @@ export function runDeterministic(cwd = process.cwd(), opts = {}) { const cacheScope = prefixCacheScope(opts.scope, effectiveIds); // Deterministic-prefix cache (ship only — a no-op otherwise): a cached all-green staged tree skips // every gate. checkPrefix returns true = skip, false = run. - const cachedPrefix = prefixEntry(cwd, { hookPath: opts.hookPath, scope: cacheScope }); - const skip = Boolean(cachedPrefix); - const bypassStructure = Boolean(opts.structure) && structureBypassed(); - const fails = []; + const cachedPrefix = prefixEntry(cwd, { hookPath: opts.hookPath, scope: cacheScope }); + const skip = Boolean(cachedPrefix); + const bypassStructure = Boolean(opts.structure) && structureBypassed(); + const fails = []; // Gates that opted out (exit 2 where that IS an opt-out) and so proved nothing. Reported even on a // green run — the whole defect this exists for is a skipped gate reading like a passed one. - const skipped = []; - if (!skip) { - if (bypassStructure) { - console.log('⚠️ Structure lint BYPASSED for this run (GUARD_STRUCTURE_OK=1).'); - console.log(' Repository structure was NOT verified for this commit.'); - emitGateEvent({ - type: 'gate_result', - gate: 'structure-lint', - // The collector's gate_result schema accepts fail | could_not_run. Keep the deliberate - // bypass measurable as a non-run, and distinguish it from an infrastructure opt-out in - // detail instead of inventing a status that downstream readers would treat as clean. - status: 'could_not_run', - detail: 'structure-lint(bypassed:GUARD_STRUCTURE_OK)', - }); - } - const ids = new Set(effectiveIds); + const skipped = []; + if (!skip) { + if (bypassStructure) { + console.log('⚠️ Structure lint BYPASSED for this run (GUARD_STRUCTURE_OK=1).'); + console.log(' Repository structure was NOT verified for this commit.'); + emitGateEvent({ + type: 'gate_result', + gate: 'structure-lint', + // The collector's gate_result schema accepts fail | could_not_run. Keep the deliberate + // bypass measurable as a non-run, and distinguish it from an infrastructure opt-out in + // detail instead of inventing a status that downstream readers would treat as clean. + status: 'could_not_run', + detail: 'structure-lint(bypassed:GUARD_STRUCTURE_OK)', + }); + } + const ids = new Set(effectiveIds); const gates = DETERMINISTIC.filter((g) => ids.has(g.id)).map((g) => ({ label: `guard-${g.id}`, argv: ['node', path.resolve(HERE, g.module.replace(MJS_EXT_RE, SELF_EXT)), ...g.args], diff --git a/dist/gate-engine/ratchets/size-disable.mjs b/dist/gate-engine/ratchets/size-disable.mjs index 3ebb2650..7a9d3def 100644 --- a/dist/gate-engine/ratchets/size-disable.mjs +++ b/dist/gate-engine/ratchets/size-disable.mjs @@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { CONFIG_FILENAME, resolveGuardConfig, sourceMatchers } from "../config.mjs"; -import { hasStagedFiles, stageBaseline, stagedSet } from "./git-index.mjs"; +import { hasStagedFiles, pullRequestScope, stageBaseline, stagedSet } from "./git-index.mjs"; import { LINES_BASELINE, SIZE_SKIP_DIRS } from "./size-policy.mjs"; import { runPreflightCli } from "./size-preflight.mjs"; const BASELINE = 'eslint/baselines/size.json'; @@ -194,7 +194,7 @@ export function previewGrandfather(cwd) { // no commit to carry a baseline change. Exits 1 on a file over its ceiling. // Reason: sequential grow-check then per-file auto-lower, each a trivial guard at low nesting; splitting scatters one gate decision // fallow-ignore-next-line complexity -function runLinesGate(root, cfg, linesBaselineFile) { +function runLinesGate(root, cfg, linesBaselineFile, ciScope) { const over = countOversized(root); const grandfathered = existsSync(linesBaselineFile) ? JSON.parse(readFileSync(linesBaselineFile, 'utf8')).files @@ -203,8 +203,9 @@ function runLinesGate(root, cfg, linesBaselineFile) { const inCommit = staged !== null && hasStagedFiles(root); const match = sourceMatchers(cfg.sourceExtensions); const cap = (f) => (match.isTest(f) ? cfg.maxTestLines : cfg.maxLines); - // Scope to the committing files; with nothing staged, fall back to the whole tree (CI). - const scoped = inCommit ? over.filter((o) => staged?.has(o.file)) : over; + // A PR supplies an exact base scope; local commits use the index; audits use the whole tree. + const selected = ciScope ?? (inCommit ? staged : null); + const scoped = selected ? over.filter((o) => selected.has(o.file)) : over; // A file fails when it exceeds its own recorded ceiling (grandfathered) or the cap (new file). const grew = scoped.filter((o) => o.lines > Math.max(cap(o.file), grandfathered[o.file] ?? 0)); if (grew.length) { @@ -214,8 +215,8 @@ function runLinesGate(root, cfg, linesBaselineFile) { } process.exit(1); } - if (!inCommit || !staged) - return; // no commit in progress → never tighten/stage + if (ciScope || !inCommit || !staged) + return; // CI never tightens/stages // Tighten only the committing files' ceilings; every other recorded count is preserved as-is, // so a concurrent agent's uncommitted shrink is never locked in. const next = { ...grandfathered }; @@ -266,21 +267,21 @@ function readDisableBaseline(baselineFile) { // gate blocks (its counts aren't recognised); a stale {0,0} self-deletes in the commit. // Reason: sequential grow-check then per-file auto-lower, each a trivial guard at low nesting; one gate decision, mirrors runLinesGate // fallow-ignore-next-line complexity -function runDisableGate(root, baselineFile, current) { +function runDisableGate(root, baselineFile, current, ciScope) { const { grandfathered, legacy } = readDisableBaseline(baselineFile); const cur = current.perFile; const staged = stagedSet(root); const inCommit = staged !== null && hasStagedFiles(root); const ceil = (f) => grandfathered[f] ?? { file: 0, fn: 0 }; - // A file fails when its disables exceed its recorded ceiling (0 for an unlisted/new file). Scope to - // the committing files; with nothing staged, the whole tree (CI). A LEGACY baseline is always - // whole-tree: it has no per-file grandfathering, so any disable ANYWHERE is unrecognised and must - // block the migrate — else an unstaged disable slips past and the commit path below deletes + // A file fails when its disables exceed its recorded ceiling (0 for an unlisted/new file). A PR + // scopes to its diff. Otherwise a LEGACY baseline stays whole-tree: it has no per-file + // grandfathering, so an unstaged disable must block rather than let the commit path below delete // size.json wholesale (changed=legacy, empty map), silently un-grandfathering it. - const scoped = legacy - ? Object.keys(cur) - : inCommit - ? [...staged] + const selected = legacy ? null : (ciScope ?? (inCommit ? staged : null)); + const scoped = selected + ? [...selected] + : legacy + ? Object.keys(cur) : Object.keys({ ...cur, ...grandfathered }); const grew = scoped.filter((f) => cur[f] && (cur[f].file > ceil(f).file || cur[f].fn > ceil(f).fn)); if (grew.length) { @@ -295,7 +296,7 @@ function runDisableGate(root, baselineFile, current) { console.error(' Split the file below the cap instead of disabling.'); process.exit(1); } - if (!inCommit || !staged) { + if (ciScope || !inCommit || !staged) { // No commit in progress → never mutate. Nudge a re-freeze if anything shrank or a legacy file lingers. if (legacy) { console.log(`✓ ${BASELINE} is a pre-per-file baseline — run \`guard-size freeze\` to migrate.`); @@ -374,6 +375,7 @@ function runCli(cmd) { // Reason: the two ratchets (folder-fanout / size-disable) are parallel-by-design independent guard bins (+ tests); each self-contained with the same freeze/gate CLI shell // fallow-ignore-next-line code-duplication if (cmd === 'gate') { + const ciScope = pullRequestScope(root); const hasBaseline = existsSync(baselineFile); // A missing baseline means "no grandfathered debt". Enforce from config (empty baseline = 0/0) // whenever the repo is governed (guard.config.json present — true in devkit's own repo, CI, and @@ -384,10 +386,9 @@ function runCli(cmd) { process.exit(2); // ungoverned + un-frozen → fail open } // Disable ratchet: per-file, per-commit shrink-only (auto-lowers as disables are removed). - runDisableGate(root, baselineFile, current); - // Raw-line caps: a per-file, per-COMMIT shrink-only ratchet. + runDisableGate(root, baselineFile, current, ciScope); if (cfg.maxLines || cfg.maxTestLines) - runLinesGate(root, cfg, linesBaselineFile); + runLinesGate(root, cfg, linesBaselineFile, ciScope); process.exit(0); } console.error('usage: guard-size [-- path...]>'); diff --git a/dist/gate-engine/ratchets/size-preflight.mjs b/dist/gate-engine/ratchets/size-preflight.mjs index 298931f4..8d5aaf79 100644 --- a/dist/gate-engine/ratchets/size-preflight.mjs +++ b/dist/gate-engine/ratchets/size-preflight.mjs @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { resolveGuardConfig, sourceMatchers } from "../config.mjs"; -import { stagedSet } from "./git-index.mjs"; +import { gitPrefix, stagedSet } from "./git-index.mjs"; import { LINES_BASELINE, SIZE_SKIP_DIRS } from "./size-policy.mjs"; function readLinesBaseline(file) { if (!existsSync(file)) @@ -15,10 +15,7 @@ function readLinesBaselineAtRef(root, ref) { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], }); - const prefix = execFileSync('git', ['rev-parse', '--show-prefix'], { - cwd: root, - encoding: 'utf8', - }).trim(); + const prefix = gitPrefix(root); let text; try { text = execFileSync('git', ['show', `${ref}:${prefix}${LINES_BASELINE}`], { diff --git a/dist/gate-engine/review/cascade/consumer-assets.mjs b/dist/gate-engine/review/cascade/consumer-assets.mjs new file mode 100644 index 00000000..5add78a1 --- /dev/null +++ b/dist/gate-engine/review/cascade/consumer-assets.mjs @@ -0,0 +1,22 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { checklistAssetPath, hasChecklist } from "../reviewers.mjs"; +const CONSUMER_SKILL_ROOTS = ['.claude', '.agents', '.cursor']; +/** Resolve the provider-projected checklist root actually present in a consumer checkout. */ +export function consumerChecklistAssetRoot(cwd, reviewer) { + if (!hasChecklist(reviewer)) + return '.claude'; + const relativePath = checklistAssetPath(reviewer); + return (CONSUMER_SKILL_ROOTS.find((root) => existsSync(path.resolve(cwd, root, relativePath))) ?? + '.claude'); +} +/** Read one package-relative asset from its consumer-projected brief or skill root. */ +export function readConsumerReviewAsset(cwd, cfg, skillRoot, relativePath) { + const agentsPrefix = 'agents/'; + if (relativePath.startsWith(agentsPrefix)) { + const dir = cfg.review.agentsDir; + const base = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir); + return readFileSync(path.join(base, relativePath.slice(agentsPrefix.length))); + } + return readFileSync(path.resolve(cwd, skillRoot, relativePath)); +} diff --git a/dist/gate-engine/review/cascade/reviewer.mjs b/dist/gate-engine/review/cascade/reviewer.mjs new file mode 100644 index 00000000..64ecded3 --- /dev/null +++ b/dist/gate-engine/review/cascade/reviewer.mjs @@ -0,0 +1,171 @@ +import { JUDGE_ISOLATION } from "../../judge/judge-isolation.mjs"; +import { DEEP_JUDGE_TIMEOUT_MS, execJudgeAsync } from "../../judge/run-judge.mjs"; +import { renderGoverningClaudeMd } from "../claude-md.mjs"; +import { buildCappedDiffEvidence } from "../diff-evidence.mjs"; +import { attachItems } from "../evidence/items.mjs"; +import { gitCached } from "../evidence/staged-git.mjs"; +import { applyOverrideValve } from "../overrides.mjs"; +import { allowedToolsFor, escalatePrompt, hasChecklist, parseReviewVerdict, wrapConventionsPrompt, wrapPrompt, } from "../reviewers.mjs"; +import { agentBody, cleanupChecklistState, enforceChecklistContract, initializeCommitGuardChecklist, readChecklistState, withStagedFiles, } from "../runtime.mjs"; +import { consumerChecklistAssetRoot } from "./consumer-assets.mjs"; +/** Run one reviewer with checklist verification, override handling, and cleanup. */ +export async function runCascade(sel, opts) { + const { cwd } = opts; + const checklistRoot = opts.assetRoot ?? consumerChecklistAssetRoot(cwd, sel.reviewer); + cleanupChecklistState(cwd, sel.reviewer); + try { + initializeCommitGuardChecklist(cwd, sel.reviewer, checklistRoot, opts.judgeEnv); + let res = await cascadeVerdict(sel, opts, checklistRoot); + res = await enforceChecklistContract(sel, res, cwd, opts.assetRoot, async (reason) => { + if (opts.recovery === 'defer') + return { ...res, status: 'inconclusive', reason, retryable: reason }; + if (opts.recovery === 'final') + return { + ...res, + status: 'error', + reason: `reviewer checklist contract failed after one retry — ${reason}`, + }; + throw new Error(`checklist recovery has no scheduling mode — ${reason}`); + }); + const disposition = applyOverrideValve(sel, res, cwd, { + readState: () => readChecklistState(cwd, sel.reviewer), + stagedDiff: () => gitCached(cwd, [], sel.files), + }); + attachItems(res, readChecklistState(cwd, sel.reviewer), disposition); + return res; + } + finally { + cleanupChecklistState(cwd, sel.reviewer); + } +} +async function cascadeVerdict({ reviewer, files }, { cwd, cfg, exec = execJudgeAsync, firstModel = 'haiku', retryFirst = false, assetRoot, judgeEnv, checklistRecoveryReason, promptExtras, }, checklistRoot) { + const env = withStagedFiles(judgeEnv ?? process.env, reviewer, files); + const body = agentBody(cwd, cfg, reviewer.name, assetRoot); + if (body === null) + return { + name: reviewer.name, + status: 'inconclusive', + reason: `agent brief ${reviewer.name}.md missing under ${cfg.review.agentsDir} — run devkit sync-agents && devkit sync-skills`, + escalated: false, + }; + const stat = gitCached(cwd, ['--stat'], files); + const prompt = hasChecklist(reviewer) + ? wrapPrompt(body, reviewer, files, assetRoot, checklistRecoveryReason, promptExtras, checklistRoot) + : wrapConventionsPrompt(body, files, renderGoverningClaudeMd(cwd, files), promptExtras); + const input = buildCappedDiffEvidence(gitCached(cwd, [], files), stat); + const args = (promptBody, model) => [ + '-p', + promptBody, + '--model', + model, + ...JUDGE_ISOLATION, + '--allowedTools', + allowedToolsFor(reviewer, cfg, checklistRoot), + ]; + const passModel = reviewer.model ?? firstModel; + let firstOutage; + const firstOpts = { + label: `review:${reviewer.name}`, + args: args(prompt, passModel), + input, + timeout: DEEP_JUDGE_TIMEOUT_MS, + cwd, + transcript: false, + env, + onOutage: (kind) => { + firstOutage = kind; + }, + }; + let first = await exec(firstOpts); + if (first === null && retryFirst && firstOutage !== 'timeout') { + console.error(`guard-review: ${reviewer.name}: judge run failed (${firstOutage ?? 'transient'}), retrying once…`); + cleanupChecklistState(cwd, reviewer); + initializeCommitGuardChecklist(cwd, reviewer, checklistRoot, judgeEnv); + first = await exec(firstOpts); + } + if (first === null) + return { + name: reviewer.name, + status: 'inconclusive', + reason: firstOutage === 'timeout' ? 'judge timed out' : 'judge outage', + escalated: false, + model: passModel, + }; + const firstVerdict = parseReviewVerdict(first); + if (firstVerdict.verdict === 'PASS') + return { + name: reviewer.name, + status: 'pass', + reason: firstVerdict.reason, + escalated: false, + model: passModel, + transcript: first, + }; + if (firstVerdict.verdict === null) + return { + name: reviewer.name, + status: 'inconclusive', + reason: 'no VERDICT line', + escalated: false, + model: passModel, + transcript: first, + }; + if (reviewer.model) + return { + name: reviewer.name, + status: 'fail', + reason: firstVerdict.reason, + escalated: false, + model: passModel, + transcript: first, + }; + let secondOutage; + const second = await exec({ + label: `review:${reviewer.name}:escalate`, + args: args(escalatePrompt(prompt, first), 'opus'), + input, + timeout: DEEP_JUDGE_TIMEOUT_MS, + cwd, + transcript: false, + env, + onOutage: (kind) => { + secondOutage = kind; + }, + }); + if (second === null) + return { + name: reviewer.name, + status: 'inconclusive', + reason: secondOutage === 'timeout' ? 'escalation timed out' : 'escalation outage', + escalated: true, + model: passModel, + transcript: first, + }; + const finalVerdict = parseReviewVerdict(second); + if (finalVerdict.verdict === 'FAIL') + return { + name: reviewer.name, + status: 'fail', + reason: finalVerdict.reason, + escalated: true, + model: passModel, + transcript: second, + }; + if (finalVerdict.verdict === 'PASS') + return { + name: reviewer.name, + status: 'pass', + reason: finalVerdict.reason, + escalated: true, + model: passModel, + transcript: second, + }; + return { + name: reviewer.name, + status: 'inconclusive', + reason: 'no VERDICT line', + escalated: true, + model: passModel, + transcript: second, + }; +} diff --git a/dist/gate-engine/review/reviewers.mjs b/dist/gate-engine/review/reviewers.mjs index 639d4788..d477ec28 100644 --- a/dist/gate-engine/review/reviewers.mjs +++ b/dist/gate-engine/review/reviewers.mjs @@ -216,9 +216,10 @@ export function stripFrontmatter(md) { * preamble re-scopes it (staged-only, checklist-driven, no marker/approve machinery) and the * postamble pins the machine-parseable verdict line. */ -export function wrapPrompt(agentBody, reviewer, files, assetRoot, checklistRecoveryReason, { targetsBlock = '', commitMsgBlock = '' } = {}) { - const effectiveAssetRoot = assetRoot ?? '.claude'; - const brief = stripFrontmatter(agentBody).replaceAll('.claude/skills/', `${effectiveAssetRoot.replace(TRAILING_SLASH_RE, '')}/skills/`); +export function wrapPrompt(agentBody, reviewer, files, assetRoot, checklistRecoveryReason, { targetsBlock = '', commitMsgBlock = '' } = {}, checklistRoot = assetRoot ?? '.claude') { + const effectiveAssetRoot = checklistRoot; + const skillPrefix = `${effectiveAssetRoot.replace(TRAILING_SLASH_RE, '')}/skills/`; + const brief = ['.agents/skills/', '.claude/skills/', '.cursor/skills/'].reduce((body, providerPrefix) => body.replaceAll(providerPrefix, skillPrefix), stripFrontmatter(agentBody)); const script = checklistScriptAt(reviewer, effectiveAssetRoot); const checklistContract = checklistContractFor(reviewer, script, assetRoot); return ('You are running as an automated HEADLESS COMMIT GATE, not an interactive assistant.\n' + diff --git a/dist/gate-engine/review/run-review.mjs b/dist/gate-engine/review/run-review.mjs index 15222ed5..35758098 100644 --- a/dist/gate-engine/review/run-review.mjs +++ b/dist/gate-engine/review/run-review.mjs @@ -31,233 +31,26 @@ */ import { envFlag, resolveGuardConfig } from "../config.mjs"; import { emitCacheHit } from "../judge/gate-events.mjs"; -import { JUDGE_ISOLATION } from "../judge/judge-isolation.mjs"; import { reportGateInfraFailure } from "../judge/odb-probe.mjs"; -import { DEEP_JUDGE_TIMEOUT_MS, execJudgeAsync, strictRemedy } from "../judge/run-judge.mjs"; +import { execJudgeAsync, strictRemedy } from "../judge/run-judge.mjs"; import { loadCache } from "./cache.mjs"; -import { renderGoverningClaudeMd } from "./claude-md.mjs"; -import { buildCappedDiffEvidence } from "./diff-evidence.mjs"; +import { runCascade } from "./cascade/reviewer.mjs"; import { loadReviewerContext } from "./evidence/commit-message.mjs"; -import { attachItems } from "./evidence/items.mjs"; import { emitReviewScope, emitReviewSkipped, reportNonRuns } from "./evidence/scope.mjs"; import { gitCached, stagedFiles } from "./evidence/staged-git.mjs"; import { reviewerTargetSalts } from "./evidence/targets-block.mjs"; import { emitMergedLensResults, mapLimit, planReviewWork, taskLabel } from "./lens/split.mjs"; -import { applyOverrideValve } from "./overrides.mjs"; import { clearProgress, writeProgress } from "./progress.mjs"; import { retryableReason, runDeferredRecoveries, settleReviewOutcome, } from "./recovery/settle.mjs"; -import { allowedToolsFor, cacheKey, effectiveReviewConfig, escalatePrompt, hasChecklist, parseReviewVerdict, selectReviewers, wrapConventionsPrompt, wrapPrompt, } from "./reviewers.mjs"; -import { agentBody, cleanupChecklistState, enforceChecklistContract, gateJudgeEnv, initializeCommitGuardChecklist, passAssetVerifier, preflightReviewAssets, readChecklistState, resolveReviewerIdentities, skippedReviewers, withStagedFiles, } from "./runtime.mjs"; +import { cacheKey, effectiveReviewConfig, selectReviewers, } from "./reviewers.mjs"; +import { gateJudgeEnv, passAssetVerifier, preflightReviewAssets, resolveReviewerIdentities, skippedReviewers, } from "./runtime.mjs"; import { ReviewGateTiming, reviewConcurrency } from "./telemetry/timing.mjs"; +export { runCascade }; // A missing brief / missing checklist artifact is a SYNC gap, not an auth/quota outage — the strict -// remedy branches on it (see the inconclusive loop). Matches the reasons set in cascadeVerdict -// (`agent brief …`) and verifyChecklist (`checklist artifact missing …`). +// remedy branches on it (see the inconclusive loop). const SYNC_INCONCLUSIVE_RE = /^agent brief |^checklist artifact missing/; -// A cap kill, likewise, is the gate's OWN contention kill — not auth/quota. Matches the reasons -// cascadeVerdict sets from the judge's outage KIND (`judge timed out` / `escalation timed out`). +// A cap kill, likewise, is the gate's OWN contention kill — not auth/quota. const TIMEOUT_INCONCLUSIVE_RE = /timed out$/; -// Every pass here — first, strict first, opus escalation — runs on the SHARED DEEP_JUDGE_TIMEOUT_MS -// (judge/run-judge.mts), as does the commit-msg completeness judge; the 30-min rationale lives with -// the constant. Three same-valued locals here is exactly how it drifted from completeness (sc-1227). -// Budget arithmetic — the ship ceiling bounds the WHOLE hook chain, not this gate alone: deterministic -// prefix ~240s + decisions ≤60s (both ≈0 on a cache hit) + this cascade gate + completeness on the same -// cap. PER-CASCADE worst ≈ 1800 (first) + 1800 (escalate) = 3600s; under the concurrency cap (default -// 2, see the docblock) cascades run in ceil(N/K) WAVES, so the theoretical worst far exceeds -// SHIP_COMMIT_TIMEOUT (3600s) — by design: a killed ship CONVERGES on re-run because PASSes checkpoint -// per-completion and the caches skip what was earned (docs/decisions/ship-gates-converge-not-restart.md). -// Only correctness nears the cap; the rest finish <300s, so a real ship is one slow wave + fast waves. -/** - * One reviewer's cascade → {name, status: 'pass'|'fail'|'inconclusive', reason, escalated}. - * `exec` is injectable for tests; the gate always passes execJudgeAsync. - * - * Wraps the verdict cascade with the checklist-artifact contract: the state file is cleaned - * BEFORE the judge runs (a stale artifact from an interactive session must never satisfy the - * gate), a PASS is voided to inconclusive when the artifact is missing/incomplete/inconsistent - * (verifyChecklist), and the artifact is removed afterwards either way. - */ -export async function runCascade(sel, opts) { - const { cwd } = opts; - cleanupChecklistState(cwd, sel.reviewer); - try { - initializeCommitGuardChecklist(cwd, sel.reviewer, opts.assetRoot, opts.judgeEnv); - let res = await cascadeVerdict(sel, opts); - res = await enforceChecklistContract(sel, res, cwd, opts.assetRoot, async (reason) => { - // sc-1476: under 'defer', the contract miss is PARKED for the post-wave serial phase (haiku - // compliance degrades under concurrent load — retrying inside the same wave re-fails). - // Under 'final' (the deferred attempt), a repeated miss is terminal. One attempt total. - if (opts.recovery === 'defer') - return { ...res, status: 'inconclusive', reason, retryable: reason }; - if (opts.recovery === 'final') - return { - ...res, - status: 'error', - reason: `reviewer checklist contract failed after one retry — ${reason}`, - }; - // Unreachable: only the review lane sets assetRoot (the callback's gate), always with a mode. - throw new Error(`checklist recovery has no scheduling mode — ${reason}`); - }); - const disposition = applyOverrideValve(sel, res, cwd, { - readState: () => readChecklistState(cwd, sel.reviewer), - stagedDiff: () => gitCached(cwd, [], sel.files), - }); - attachItems(res, readChecklistState(cwd, sel.reviewer), disposition); - return res; - } - finally { - cleanupChecklistState(cwd, sel.reviewer); - } -} -async function cascadeVerdict({ reviewer, files }, { cwd, cfg, exec = execJudgeAsync, firstModel = 'haiku', retryFirst = false, assetRoot, judgeEnv, checklistRecoveryReason, promptExtras, }) { - const env = withStagedFiles(judgeEnv ?? process.env, reviewer, files); // sc-1439 - const body = agentBody(cwd, cfg, reviewer.name, assetRoot); - if (body === null) - // A missing brief must never be judged as an EMPTY brief (a wrapper-only prompt fake-passes): - // inconclusive → fail-open on a normal commit, fail-closed on a ship — exactly the loudness - // an updated-CLI-but-unsynced-agents consumer needs. - return { - name: reviewer.name, - status: 'inconclusive', - reason: `agent brief ${reviewer.name}.md missing under ${cfg.review.agentsDir} — run devkit sync-agents && devkit sync-skills`, - escalated: false, - }; - // A skill-less reviewer (no checklist, no Bash) gets its evidence PRE-RENDERED instead of a - // "fetch it yourself" instruction: the capped diff (diff-evidence.mts) rides on stdin exactly - // like completeness.mts's judge, and the governing CLAUDE.md rules (claude-md.mts) are baked - // into the prompt itself. - const stat = gitCached(cwd, ['--stat'], files); - const prompt = hasChecklist(reviewer) - ? wrapPrompt(body, reviewer, files, assetRoot, checklistRecoveryReason, promptExtras) - : wrapConventionsPrompt(body, files, renderGoverningClaudeMd(cwd, files), promptExtras); - // sc-1441: every judge gets capped per-file evidence on stdin, not a bare stat — a judge that - // reads real hunks up front misses less; the caps are NAMED and Bash still fetches full hunks. - const input = buildCappedDiffEvidence(gitCached(cwd, [], files), stat); - const args = (p, model) => [ - '-p', - p, - '--model', - model, - ...JUDGE_ISOLATION, - '--allowedTools', - allowedToolsFor(reviewer, cfg, assetRoot), - ]; - // A model-pinned reviewer (correctness, conventions) runs single-pass at its pinned model — no escalation. - const passModel = reviewer.model ?? firstModel; - let firstOutage; - const firstOpts = { - label: `review:${reviewer.name}`, - args: args(prompt, passModel), - input, - timeout: DEEP_JUDGE_TIMEOUT_MS, - cwd, - transcript: false, // this gate persists its own review- transcript — don't store twice - env, - onOutage: (kind) => { - firstOutage = kind; - }, - }; - let first = await exec(firstOpts); - if (first === null && retryFirst && firstOutage !== 'timeout') { - // Strict (ship) runs get ONE first-pass retry — a TRANSIENT/empty failure must not fail a ship - // closed. A TIMEOUT is NOT retried: the pass already had the full DEEP_JUDGE_TIMEOUT_MS (a - // contended judge got its time UP FRONT), so a re-run burns the same budget again past the ship - // ceiling. The escalation pass never retries: outage stays inconclusive. - // Colon (not " — ") on purpose: the ship timeout banner's awk reads ` — ` as COMPLETED. - console.error(`guard-review: ${reviewer.name}: judge run failed (${firstOutage ?? 'transient'}), retrying once…`); - cleanupChecklistState(cwd, reviewer); // a dead first pass may have left partial rows - initializeCommitGuardChecklist(cwd, reviewer, assetRoot, judgeEnv); - first = await exec(firstOpts); - } - if (first === null) - return { - name: reviewer.name, - status: 'inconclusive', - // The CAUSE rides in the reason so the strict remedy can name it (sc-1227): a cap kill is - // not an auth/quota outage, and that remedy wastes the operator's time on a healthy CLI. - reason: firstOutage === 'timeout' ? 'judge timed out' : 'judge outage', - escalated: false, - model: passModel, - }; - const firstVerdict = parseReviewVerdict(first); - if (firstVerdict.verdict === 'PASS') - // Keep the judge's one-line PASS reason (the tail of its VERDICT line) instead of dropping it — - // it flows to the telemetry event + the terminal line, and `first` is persisted as a transcript. - return { - name: reviewer.name, - status: 'pass', - reason: firstVerdict.reason, - escalated: false, - model: passModel, - transcript: first, - }; - if (firstVerdict.verdict === null) - return { - name: reviewer.name, - status: 'inconclusive', - reason: 'no VERDICT line', - escalated: false, - model: passModel, - transcript: first, - }; - // Single-pass (model-pinned) reviewer: this FAIL is final — no opus escalation to second-guess it. - if (reviewer.model) - return { - name: reviewer.name, - status: 'fail', - reason: firstVerdict.reason, - escalated: false, - model: passModel, - transcript: first, - }; - let secondOutage; - const second = await exec({ - label: `review:${reviewer.name}:escalate`, - args: args(escalatePrompt(prompt, first), 'opus'), - input, - timeout: DEEP_JUDGE_TIMEOUT_MS, // opus re-investigation; only fires pre-block, never retried - cwd, - transcript: false, // this gate persists its own review- transcript — don't store twice - env, - onOutage: (kind) => { - secondOutage = kind; - }, - }); - if (second === null) - return { - name: reviewer.name, - status: 'inconclusive', - reason: secondOutage === 'timeout' ? 'escalation timed out' : 'escalation outage', - escalated: true, - model: passModel, - transcript: first, // the first-pass FAIL evidence survives even when opus was dark - }; - const finalVerdict = parseReviewVerdict(second); - if (finalVerdict.verdict === 'FAIL') - return { - name: reviewer.name, - status: 'fail', - reason: finalVerdict.reason, - escalated: true, - model: passModel, - transcript: second, - }; - if (finalVerdict.verdict === 'PASS') - return { - name: reviewer.name, - status: 'pass', - reason: finalVerdict.reason, - escalated: true, - model: passModel, - transcript: second, - }; - return { - name: reviewer.name, - status: 'inconclusive', - reason: 'no VERDICT line', - escalated: true, - model: passModel, - transcript: second, - }; -} /** * The gate → exit code (see module contract). Selected reviewers run concurrently but BOUNDED to * `reviewConcurrency()` cascades in flight (GUARD_REVIEW_CONCURRENCY, default 6) — so under machine diff --git a/dist/gate-engine/review/runtime.mjs b/dist/gate-engine/review/runtime.mjs index 5153a550..cb2615cc 100644 --- a/dist/gate-engine/review/runtime.mjs +++ b/dist/gate-engine/review/runtime.mjs @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFileSync, rmSync } from 'node:fs'; import path from 'node:path'; +import { consumerChecklistAssetRoot, readConsumerReviewAsset } from "./cascade/consumer-assets.mjs"; import { checklistAssetPath, checklistScriptAt, hasChecklist, REVIEWERS, } from "./reviewers.mjs"; const REVIEW_ROOTS_HELPER = 'skills/_devkit/review-roots.mjs'; // Imported by every checklist script (createChecklistStore), so its bytes are execution inputs of @@ -126,20 +127,6 @@ export function preflightReviewAssets(assetRoot, selected, cfg) { } return identities; } -/** - * The SYNCED consumer copy of a packaged asset. `reviewerAssetPaths` names package-relative paths; - * a consumer keeps its briefs wherever `review.agentsDir` points (configurable) and every skill - * asset under `.claude/` — devkit's own sync convention, the same one `checklistScript` encodes. - */ -function readConsumerReviewAsset(cwd, cfg, relativePath) { - const AGENTS_PREFIX = 'agents/'; - if (relativePath.startsWith(AGENTS_PREFIX)) { - const dir = cfg.review.agentsDir; - const base = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir); - return readFileSync(path.join(base, relativePath.slice(AGENTS_PREFIX.length))); - } - return readFileSync(path.resolve(cwd, '.claude', relativePath)); -} /** * Per-reviewer prompt identity for the ordinary commit/ship path, where there is no packaged asset * root and `preflightReviewAssets` therefore never runs. This is what makes a production verdict @@ -174,7 +161,8 @@ export function resolveReviewerIdentities(reviewMode, identitySalts, selected, c } export function consumerReviewerIdentity(cwd, cfg, reviewer) { try { - return hashReviewerIdentity((rel) => readConsumerReviewAsset(cwd, cfg, rel), reviewer, cfg); + const skillRoot = consumerChecklistAssetRoot(cwd, reviewer); + return hashReviewerIdentity((rel) => readConsumerReviewAsset(cwd, cfg, skillRoot, rel), reviewer, cfg); } catch { return null; diff --git a/dist/package.json b/dist/package.json index bec0b909..581b6e22 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "@norvalbv/devkit", - "version": "0.50.0", + "version": "0.51.0", "private": true, "type": "module", "license": "MIT", diff --git a/package.json b/package.json index bec0b909..581b6e22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@norvalbv/devkit", - "version": "0.50.0", + "version": "0.51.0", "private": true, "type": "module", "license": "MIT",