A
Communityworkflow
Adversarial Pre-PR Review Gate (gate-loop)
一个执行「制作者≠审查者」预 PR 门禁的 Claude Code 工作流:对已提交的 diff 并行派发只读审查子代理,仅对失败项重新派发,跑有上限的修复轮次,遇到不收敛即停止,并逐字保留每条裁定以便贴回 PR。
概览安全
实现了更大型「拴绳自治」框架(Creance)的第 7 节:给定 taskId 与从 .claude/MODELS.md 解析出的强/廉价模型档位,针对 `git diff main..HEAD` 派发审查者名单(spec-auditor、constitution-auditor,以及按条件加入的 contract- 与 spec-quality-auditor),按严格 schema 收集 PASS/JUSTIFY/FAIL 裁定;遇 FAIL 则由制作者角色代理提交最小范围修复,再只对失败的审查者重审。它携带逐字遥测载荷,区分隔离工作区(就地门禁)与共享工作树两种评审模式,并在每轮修复前恢复任务分支。
/* global args, agent, parallel, log */
// .claude/workflows/gate-loop.js — the Claude Code binding of the [orchestrated run]
// role. The runtime-neutral spec is .claude/workflow/gate-loop.md (next-task §7 as
// code). This script owns §7 steps 2/4/5 — parallel reviewer fan-out, fix-and-
// re-dispatch, the non-convergence stop, verbatim verdict retention. The maker's
// self-review (§7.1), /code-review (§7.3), and posting the verdicts to the PR (§8)
// stay with the invoker. Invoke it on a task branch with the work COMMITTED — the
// reviewers audit `git diff main..HEAD`. Under an engaged isolated autonomous run
// (next-task §0.5/§4), pass `workspacePath` so the reviewers and the fixer audit the
// committed diff of the [isolated workspace] at that path instead of the main working
// tree (gate-in-place, T612) — the path is passed EXPLICITLY, never inferred from CWD
// (the explicit-context rule). Omit it for review mode and the gate is byte-identical.
//
// Every return path carries `telemetry`: the gate-run record payload defined by
// workflow/telemetry.md, minus the timestamp/repo envelope and the introducing-commit
// ref (this runtime has no clock or filesystem). The DISPATCHER appends it to the
// telemetry stream after the run returns — stamping timestamp/repo and `commit` (the
// audited HEAD) then — so a failed write can never block, fail, or alter the gate
// (telemetry observes; it never decides).
//
// Models are NEVER named here: .claude/MODELS.md is the adapter's only model-naming
// file. The invoker resolves the tier rows and passes them in `args`:
// taskId (required) task/issue ID the acceptance reviewer grades against
// strongModel (required) MODELS.md [strong tier] row — the constitution floor
// cheapModel (required) MODELS.md [cheap tier] row — acceptance + contract
// dispatchContract (default false) true when the diff touches a provider
// interface, monetization, or the data model (§7's rule)
// dispatchSpec (default false) true when the diff adds, edits, or renames a
// specs/*/spec.md (git A/M/R; a pure deletion D does not fire)
// maxFixRounds (default 2) the §7 non-convergence bound
// fix (default true) false → one report-only fan-out, no fix stage
// workspacePath (optional) the [isolated workspace] path whose committed diff the
// reviewers + fixer audit (gate-in-place under autonomous mode, T612);
// absent → the main working tree (review mode, unchanged)
// taskBranch (optional; review mode) the task branch the dispatcher cut — passed so the
// fix step restores the SHARED working tree onto it before each fix/re-dispatch
// round (#140/T622); absent under autonomous mode (the work is isolated)
export const meta = {
name: 'gate-loop',
description:
'next-task §7 pre-PR gate as code: parallel auditor fan-out, fix, re-dispatch only failures, two-round non-convergence stop, verdicts kept verbatim',
whenToUse:
'From /next-task §7 on a committed task branch. Requires args {taskId, strongModel, cheapModel} — the invoker resolves the models from .claude/MODELS.md.',
phases: [
{ title: 'Dispatch', detail: 'parallel reviewer fan-out (re-dispatch: failures only)' },
{ title: 'Fix', detail: 'maker-role agent commits minimal fixes for blocking findings' },
],
};
// Tolerate a JSON-encoded string args (an invoker that stringifies reaches the script
// as one string); anything else unparseable falls through to the hard errors below.
let input = args;
if (typeof input === 'string') {
try {
input = JSON.parse(input);
} catch {
input = null;
}
}
if (!input || !input.taskId) {
throw new Error(
'gate-loop: args.taskId is required — the acceptance reviewer cannot grade without it.',
);
}
if (!input.strongModel || !input.cheapModel) {
throw new Error(
'gate-loop: args.strongModel and args.cheapModel are required. Resolve them from the ' +
'[strong tier] / [cheap tier] rows of .claude/MODELS.md and pass them explicitly — the ' +
'constitution floor must never depend on inherited session state.',
);
}
const MAX_FIX_ROUNDS = input.maxFixRounds === undefined ? 2 : input.maxFixRounds;
const APPLY_FIXES = input.fix === undefined ? true : Boolean(input.fix);
// The whole report is captured verbatim — `report` is exactly what §8 posts to the PR,
// so the gate's evidence cannot be lost or paraphrased.
const VERDICT_SCHEMA = {
type: 'object',
required: ['verdict', 'report'],
properties: {
verdict: { type: 'string', enum: ['PASS', 'JUSTIFY', 'FAIL'] },
report: {
type: 'string',
description:
'Your complete verdict report exactly as your spec defines it (item-by-item table, ' +
'overall verdict, evidence) — verbatim markdown; it is posted to the PR unchanged.',
},
},
};
// Single-quote a path for safe embedding in a generated shell command. A valid workspace temp
// path may legitimately contain spaces or shell metacharacters; embedded unquoted it would split
// into multiple args and malform the `git -C <path>` command the reviewers/fixer are told to run
// (Codex P2, PR #114). POSIX single-quoting: wrap in '…', and close/escape/reopen each embedded
// single quote ('\'') so the whole path stays one argument verbatim.
const shellQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
// Where the reviewers (and the fixer) read the committed diff. Default: the main working
// tree (review mode). Under an engaged isolated autonomous run the invoker passes
// `workspacePath`, and the diff is read from THAT worktree via an explicit `git -C <path>`
// (gate-in-place, T612) — explicit context, never an inferred CWD. The `-C` form means the
// reviewer subagents audit the workspace's diff even though they run from the session CWD.
// The path is shell-quoted in the runnable command so a space/metacharacter cannot malform it.
const WORKSPACE = input.workspacePath;
const TASK_BRANCH = input.taskBranch; // review-mode fix-round restore target (#140/T622); absent under autonomous mode
const diffCmd = WORKSPACE ? `git -C ${shellQuote(WORKSPACE)} diff main..HEAD` : `git diff main..HEAD`;
// The non-switching base READ for `git show` — same tree-scoping as diffCmd. The auditors are told
// to read base state by non-switching means; those example commands must point at the SAME tree they
// audit, so under an isolated run they carry the explicit `git -C <workspace>` and a session-CWD
// reviewer is never steered back to the shared tree (Codex P2 / craft, PR #174).
const showCmd = WORKSPACE ? `git -C ${shellQuote(WORKSPACE)} show main:<path>` : `git show main:<path>`;
const diffTarget = WORKSPACE
? `the committed diff of the ISOLATED WORKSPACE at ${WORKSPACE} (run \`${diffCmd}\` — ` +
`that worktree holds the autonomous run's branch; do NOT audit the main working tree)`
: `the current branch's committed diff (\`${diffCmd}\`)`;
const reviewerPrompt =
`Task under review: ${input.taskId}. Audit ${diffTarget} per your spec. Set 'verdict' ` +
`to your overall verdict and put your full verdict report, verbatim, in 'report'. ` +
// Prevention layer for #140/T622 (the deterministic backstop is the restore-task-branch step —
// run by the loop before each fix/re-dispatch and by the dispatcher after the run): in review
// mode you run in the maker's SHARED working tree, so a branch switch here silently relocates
// their HEAD off the task branch. Read base state by NON-SWITCHING means only — and the example
// reads reuse diffCmd/showCmd, so under an isolated run they carry the explicit `git -C <workspace>`
// target; a bare `git diff main..HEAD` would steer a session-CWD reviewer back to the shared tree
// and recreate the T612 vacuous pass (Codex P2 / craft, PR #174).
`Read base state by NON-SWITCHING means only — \`${diffCmd}\`, \`${showCmd}\`, ` +
`or a throwaway \`git worktree\` — and NEVER run \`git checkout\`/\`git switch\` against the ` +
`working tree you are auditing; switching it would relocate the maker's HEAD off the task branch.`;
// DERIVED FROM the reviewer roster in workflow/gate-loop.md → "The reviewer roster" — the
// single source of truth for gate membership, tier, and dispatch-condition. This array is
// the adapter-side encoding of that table: spec-auditor (cheap/always) + constitution-
// auditor (strong/always) as the unconditional set, contract-auditor (cheap) pushed only
// under the `dispatch-contract` condition, and spec-quality-auditor (strong) pushed only
// under the `dispatch-spec` condition. Do NOT edit membership/tier/condition here in
// isolation — change the roster, then mirror it here. reviewer-roster.test.sh (CI `verify`)
// FAILs if this site drifts from the roster.
//
// `tier` is the bracketed tier name the reviewer is dispatched at — it is what the
// telemetry record carries (telemetry.md: tier names, never model IDs; the model args
// are never copied into the payload).
const reviewers = [
{ key: 'spec-auditor', model: input.cheapModel, tier: 'cheap' },
{ key: 'constitution-auditor', model: input.strongModel, tier: 'strong' },
];
if (input.dispatchContract) {
reviewers.push({ key: 'contract-auditor', model: input.cheapModel, tier: 'cheap' });
}
if (input.dispatchSpec) {
reviewers.push({ key: 'spec-quality-auditor', model: input.strongModel, tier: 'strong' });
}
const verdicts = {}; // reviewer key → its LATEST {verdict, report}, verbatim
let pending = reviewers;
let fixRoundsUsed = 0;
// Telemetry accumulators for the `gate-run` record (workflow/telemetry.md). The loop
// only BUILDS the payload; the dispatcher appends it to the stream after the run
// returns — so a failed write structurally cannot alter the gate outcome.
const roundsHistory = []; // one entry per dispatch round: [{ auditor, tier, verdict }]
const failReports = {}; // "<auditor>:round-<n>" → verbatim FAIL report, or "NO-RESULT"
// The record minus the fields the script cannot produce (timestamp needs a clock; repo
// and `commit` — the audited HEAD — need the filesystem) — the dispatcher stamps those
// at append time, reading `commit` after the run so fix-round commits are included.
const telemetry = (outcome) => ({
record: 'gate-run',
task_id: input.taskId,
rounds: roundsHistory,
fix_rounds_used: fixRoundsUsed,
outcome,
fail_reports: failReports,
});
while (true) {
log(
`Gate dispatch ${fixRoundsUsed + 1}/${MAX_FIX_ROUNDS + 1}: ${pending.map((r) => r.key).join(', ')}`,
);
const results = await parallel(
pending.map(
(r) => () =>
agent(reviewerPrompt, {
label: r.key,
phase: 'Dispatch',
agentType: r.key,
model: r.model,
schema: VERDICT_SCHEMA,
}),
),
);
const roundNumber = fixRoundsUsed + 1;
roundsHistory.push(
pending.map((r, i) => ({
auditor: r.key,
tier: r.tier,
verdict: results[i] ? results[i].verdict : 'NO-RESULT',
})),
);
pending.forEach((r, i) => {
if (!results[i]) {
// A NO-RESULT dispatch has no report — the record carries the literal marker.
failReports[`${r.key}:round-${roundNumber}`] = 'NO-RESULT';
} else if (results[i].verdict === 'FAIL') {
failReports[`${r.key}:round-${roundNumber}`] = results[i].report;
}
});
pending.forEach((r, i) => {
if (results[i]) verdicts[r.key] = results[i];
});
// A reviewer that returned nothing (skipped/errored dispatch) is never a pass — it
// stays failing, gets re-dispatched, and if it never reports, the gate FAILs with it
// listed under noVerdict.
const failing = pending.filter((r, i) => !results[i] || results[i].verdict === 'FAIL');
if (failing.length === 0) {
return {
gate: 'PASS',
// JUSTIFY clears the gate only with the deviation documented in the PR body.
justified: Object.keys(verdicts).filter((k) => verdicts[k].verdict === 'JUSTIFY'),
verdicts,
fixRoundsUsed,
telemetry: telemetry('pass'),
};
}
if (!APPLY_FIXES || fixRoundsUsed >= MAX_FIX_ROUNDS) {
// `non-convergence` is the §7.4 stop — the fix budget ran out with a reviewer
// still failing. A report-only run (fix: false) that fails is a plain `fail`.
const outcome = APPLY_FIXES && fixRoundsUsed >= MAX_FIX_ROUNDS ? 'non-convergence' : 'fail';
return {
// Non-convergence: stop and surface in the PR body (§7.4) — the loop never
// overrides a reviewer.
gate: 'FAIL',
failing: failing.map((r) => r.key),
noVerdict: pending.filter((r, i) => !results[i]).map((r) => r.key),
verdicts,
fixRoundsUsed,
telemetry: telemetry(outcome),
};
}
// Review-mode fix-round restore (#140/T622; Codex P2, PR #174). We are past the PASS/stop
// returns, so the loop is about to apply a fix and/or re-dispatch. BEFORE either, put the SHARED
// working tree's HEAD back on the task branch: a parallel read-only auditor (read-only as to file
// mutation, but it holds Bash) can leave HEAD relocated off the task branch, and then the fixer
// would commit onto the wrong branch and the re-dispatched reviewers would audit the wrong HEAD.
// This is the SAME deterministic, fail-loud restore the dispatcher runs after the loop — applied
// here at the fix boundary so the gap is closed on every fix/re-dispatch path, not only the
// terminal post-run step. The loop's runtime has no git, so the restore is performed by a
// dispatched agent (the only actor here with VCS access); it makes no code change. Workspace mode
// isolates the work in the ephemeral [isolated workspace], so there is no shared tree to restore
// (TASK_BRANCH is absent there).
if (!WORKSPACE && TASK_BRANCH) {
await agent(
`Before the pre-PR gate for task ${input.taskId} applies any fix or re-dispatches its ` +
`reviewers, restore the shared working tree to its task branch — a parallel read-only ` +
`auditor in this same tree may have left HEAD relocated onto another branch. Run exactly ` +
`this from the repo root, and nothing else (make NO code change):\n\n` +
` bash .claude/hooks/restore-task-branch.sh ${shellQuote(TASK_BRANCH)}\n\n` +
`It is a no-op when nothing drifted. If it exits non-zero it could not guarantee the ` +
`branch — STOP and report that in your final message; do not let the gate fix or re-audit ` +
`a drifted HEAD.`,
{ label: `restore-round-${fixRoundsUsed + 1}`, phase: 'Fix' },
);
}
const findings = failing.filter((r) => verdicts[r.key] && verdicts[r.key].verdict === 'FAIL');
if (findings.length > 0) {
log(
`Fix round ${fixRoundsUsed + 1}: addressing FAIL findings from ${findings.map((r) => r.key).join(', ')}`,
);
const briefs = findings
.map((r) => `## ${r.key} — FAIL (verbatim)\n\n${verdicts[r.key].report}`)
.join('\n\n');
// No model override: the fixer inherits the session model — the task's tier by
// construction (the per-stage tier map in workflow/next-task.md).
// The fixer works WHERE the diff is read: the [isolated workspace] worktree under
// autonomous mode (so its commits land on the task branch the reviewers re-audit), else
// the current task branch. Either way, never main.
const workLocation = WORKSPACE
? `inside the ISOLATED WORKSPACE at ${WORKSPACE} (cd into it and work there — that ` +
`worktree holds the task branch, NOT main; never switch to or commit on main)`
: `on the current task branch (you are NOT on main; never switch to or commit on main)`;
await agent(
`You are the maker addressing blocking pre-PR gate findings for task ${input.taskId}, ` +
`${workLocation}.\n\n` +
`${briefs}\n\n` +
`Apply the minimal scoped change that addresses each blocking finding — nothing ` +
`speculative. Run the tests for whatever you change. Stage SPECIFIC files (never ` +
`'git add .') and commit on this branch with message ` +
`"fix: [${input.taskId}] address gate findings (round ${fixRoundsUsed + 1})". The ` +
`reviewers re-audit the COMMITTED diff (${diffCmd}), so an uncommitted fix ` +
`is invisible to them. If you judge a finding wrong or out of scope, leave the code ` +
`unchanged for that finding and say why in your final message — never override the ` +
`reviewer yourself; the gate surfaces non-convergence instead.`,
{ label: `fix-round-${fixRoundsUsed + 1}`, phase: 'Fix' },
);
}
fixRoundsUsed += 1;
pending = failing; // re-dispatch ONLY the failures
}
相关推荐
Coherence Coach
AWeirdDev
监看对话,找出助手可能忽略的上下文,仅在确有必要时给出提醒,否则返回 null。
promptmarkdown
React + Tailwind Product Card Component Prompt
AmirMotefaker
让模型扮演资深前端工程师,生成可用于生产环境、响应式的 React + Tailwind CSS 商品卡片组件(TypeScript)。
promptmarkdown
Secure JWT Authentication Middleware
AmirMotefaker
一个「角色/任务/规则」结构的提示词模板,要求模型为 Node.js + Express 设计安全的 JWT 认证中间件,包含 bcrypt 密码哈希与完整错误处理。
promptmarkdown