Claude Code 플러그인을 위한 결정론적 보장: 읽기 전용 리뷰어 에이전트, Stop/PreToolUse 훅, 그리고 eval
README 링크에서 발견 · today
스킬 텍스트에 있던 플러그인 보장 사항을 강제 적용되는 에이전트와 훅으로 옮긴 다음,
What they did
에이전틱 개발 워크플로 플러그인의 메인테이너가 가장 중요한 보장 사항들을 산문 지시문에서 Claude Code가 실제로 지원하는 강제 지점으로 옮기고, 스킬 텍스트는 다른 하네스를 위한 이식 가능한 폴백으로 유지했다. 구체적으로: (1) 세 개의 플러그인 에이전트 — quality-reviewer, spec-reviewer, standards-reviewer — 를 디스패치하여 리뷰를 읽기 전용으로 만들었으며, 각 에이전트에는 Read, Grep, Glob만 부여하고 `skills:`를 통해 스킬을 미리 로드하도록 했고, 다른 도구는 거부하는 검증기를 두었다; (2) 한 턴에서 `reviewing-change`를 실행했는데 세 리뷰어를 모두 디스패치하지 않은 경우, diff 크기와 무관하게 Claude를 한 번 되돌려 보내는 `Stop` 훅(`guard-review.mjs`)을 추가했으며, 리뷰어별로 마커 파일을 하나씩 두어 병렬 디스패치에서도 업데이트가 유실되지 않게 하고, fail open으로 동작한다; (3) 원격 기본 브랜치, `--all`, `--mirror`로의 푸시를 차단하는 `PreToolUse` 훅(`guard-push.mjs`)을 추가했고, `cd`와 줄 연속을 따라가며, `guard_default_branch` 플러그인 옵션으로 비활성화할 수 있다; (4) developing-feature, fixing-bug, committing-change, shipping-change에 무인 실행 계약을 작성했다 — 프롬프트가 유일한 권한이며, 열린 결정은 보고서에 기록되고, merge/deploy/release는 항상 사람을 기다린다; (5) 중첩 지시 파일이나 `.claude/rules/`를 통해 교훈을 해당 디렉터리 범위로 한정했다; (6) 스킬별 점수와 델타를 위한 `pnpm eval:report`를 추가했고, 에이전트 작업에 쓰기 자격 증명이 없는 CI-fixer 템플릿과 에이전트 지원 변경에 대한 지표를 추가했다. 이들은 155/155 테스트, `git rebase --exec`를 통한 커밋별 `pnpm test` 및 `pnpm check`, 깨끗한 `pnpm build`, `claude plugin validate --strict`, `claude --plugin-dir` 하의 런타임 수용 시나리오 30–32, 그리고 haiku 심판과 함께 arm당 3회 실행의 7개 eval 케이스로 검증했다.
How it went
0.6.0 릴리스로 배포되었습니다. 155개 테스트가 모두 통과하고, 모든 커밋이 test와 check를 독립적으로 통과하며, 빌드가 트리를 깨끗하게 남기고, 플러그인 검증과 릴리스 체크도 통과합니다. 런타임 시나리오에서 Stop 훅이 리뷰어를 건너뛴 리뷰를 차단했고, push 훅은 옵션이 켜져 있을 때 기본 브랜치 push를 차단하고 옵션이 꺼져 있을 때 허용했으며, unattended 계약도 유지되었습니다. Eval에서 큰 차이가 나타났습니다: review-readonly-agents 1.00 대 0.40, commit/bugfix unattended 1.00 대 0.33, feature 및 ship unattended 1.00 대 0.00, lessons-path-scoped 1.00 대 0.50. 플러그인 없이는 모든 unattended 케이스가 main에 커밋하거나 push했습니다. 첫 eval 실행에서 실제 버그도 발견되었습니다: grader가 JavaScript RegExp가 거부하는 `(?i)`를 사용하고 있었고, `flags: i`로 수정하고 모든 grader 정규식을 컴파일하는 테스트를 추가했습니다.
Worth knowing
Hooks와 에이전트는 Claude Code 전용입니다. Codex, Cursor, Gemini CLI, OpenCode는 스킬 텍스트만 유지하며, 이는 여전히 읽기 전용 서브에이전트와 기본 브랜치 푸시 금지만을 지시할 뿐입니다. `guard-push`는 가드이지 샌드박스가 아닙니다 — 따옴표로 묶인 구분자, `bash -c`, 서브셸, 변수, glob refspec, 또는 `push.default=upstream` 상태의 맨 `git push`를 파싱하지 않습니다. `guard-review`는 `stop_hook_active`를 통해 턴을 최대 한 번만 되돌려 보내므로, 루프에 빠질 수는 없지만 재강제도 할 수 없습니다. 전체 47개 케이스 스위트는 이 브랜치에서 실행되지 않았고, `lessons-path-scoped-claude-md` 케이스는 플러그인 없이도 통과하므로 회귀만 잡아냅니다. 또한 `guard-push`에는 테스트가 있지만 eval 케이스는 없습니다. 롤백은 머지를 되돌리거나 0.5.3으로 고정하는 것이며, 훅 상태는 `${CLAUDE_PLUGIN_DATA}` 또는 임시 디렉터리에 저장됩니다.
Wiring
플러그인은 `plugins/workflows` 아래에 위치하며, Claude Code 2.1.300에서 `claude --plugin-dir plugins/workflows`로 런타임에 로드됩니다. 리뷰어 에이전트는 제한된 `tools` 목록(Read, Grep, Glob)과 `skills:` 프리로드를 가진 플러그인 에이전트로 선언되며, 리뷰 스킬은 세 에이전트를 모두 이름으로 디스패치합니다(`workflows:*-reviewer`). 훅은 플러그인의 훅 설정에 등록됩니다: `guard-review.mjs`를 가리키는 `Stop` 훅과 `guard-push.mjs`를 가리키는 `PreToolUse` 훅입니다. `guard-review`는 `${CLAUDE_PLUGIN_DATA}`(또는 임시 디렉터리) 아래에 리뷰어당 하나의 마커 파일을 작성하고, `stop_hook_active`를 읽어 자신을 한 번의 바운스로 제한합니다. `guard-push`는 Bash 명령 문자열을 검사하고, `cd`와 줄 연속을 해석하며, 대상 ref를 원격 기본 브랜치와 비교하고, `guard_default_branch` 플러그인 옵션을 존중합니다. 스킬 텍스트는 다른 하네스를 위해 동일한 규칙을 담고 있습니다. 검증은 `pnpm test`, `pnpm check`, `pnpm build`, `pnpm eval:report`, `node scripts/release.mjs check main`, `claude plugin validate plugins/workflows --strict`, 그리고 arm당 3회 실행과 haiku 심사자를 사용하는 `claude plugin eval`을 통해 수행되며, 런타임 수용 시나리오는 `docs/runtime-acceptance.md`에 있습니다.Try it yourself
# Hardening the Claude Code plugin: enforced vs. instructed guarantees
Below is the full plan and implementation, in the order you asked. I'll flag at each step whether the guarantee is **enforced** (a hook or validator will actually block/redirect) or **instructed** (skill text only, honored by the model but not mechanically checked).
---
## 1. Split the review skill into three read-only reviewer agents
### 1a. Locate the review skill
The review skill lives at `plugins/<plugin>/skills/review/SKILL.md` (adjust to your repo layout). It currently bundles quality, spec, and standards checks into one skill body.
### 1b. Three reviewer agents
Create `plugins/<plugin>/agents/` with one file per reviewer. Each declares **only** `Read`, `Grep`, `Glob` and preloads the shared review skill via `skills:`.
`plugins/<plugin>/agents/reviewer-quality.md`
```markdown
---
name: reviewer-quality
description: Read-only reviewer focused on code quality (correctness, clarity, dead code, error handling). Use when a review needs the quality lens.
tools: Read, Grep, Glob
skills:
- review
---
You are the **quality** reviewer. You are strictly read-only.
Scope:
- Correctness and edge cases
- Clarity, naming, dead code, duplication
- Error handling and failure modes
Do not comment on spec conformance or house style — other reviewers own those.
Return findings as a list: `file:line — severity — issue — suggested fix`.
```
`plugins/<plugin>/agents/reviewer-spec.md`
```markdown
---
name: reviewer-spec
description: Read-only reviewer focused on spec conformance (does the change do what was asked, nothing more). Use when a review needs the spec lens.
tools: Read, Grep, Glob
skills:
- review
---
You are the **spec** reviewer. You are strictly read-only.
Scope:
- Does the change satisfy the stated requirement?
- Scope creep: anything added that wasn't asked for
- Missing acceptance criteria
Do not comment on code quality or house style.
Return findings as a list: `file:line — severity — issue — suggested fix`.
```
`plugins/<plugin>/agents/reviewer-standards.md`
```markdown
---
name: reviewer-standards
description: Read-only reviewer focused on repo standards and conventions (lint rules, patterns, docs, tests). Use when a review needs the standards lens.
tools: Read, Grep, Glob
skills:
- review
---
You are the **standards** reviewer. You are strictly read-only.
Scope:
- Repo conventions and documented standards
- Test coverage expectations
- Docs and changelog requirements
Do not comment on code quality or spec conformance.
Return findings as a list: `file:line — severity — issue — suggested fix`.
```
### 1c. Validator that rejects any other tool
Add a validator script that parses each agent's frontmatter and fails if `tools` contains anything outside `{Read, Grep, Glob}`.
`plugins/<plugin>/scripts/validate-agents.mjs`
```js
#!/usr/bin/env node
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const ALLOWED = new Set(["Read", "Grep", "Glob"]);
const dir = process.argv[2] ?? "plugins/<plugin>/agents";
let failed = false;
for (const file of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
const text = readFileSync(join(dir, file), "utf8");
const fm = text.match(/^---\n([\s\S]*?)\n---/);
if (!fm) { console.error(`${file}: missing frontmatter`); failed = true; continue; }
const toolsLine = fm[1].match(/^tools:\s*(.+)$/m);
if (!toolsLine) { console.error(`${file}: missing tools:`); failed = true; continue; }
const tools = toolsLine[1].split(",").map((t) => t.trim()).filter(Boolean);
const bad = tools.filter((t) => !ALLOWED.has(t));
if (bad.length) {
console.error(`${file}: disallowed tools: ${bad.join(", ")}`);
failed = true;
}
if (!/^skills:/m.test(fm[1])) {
console.error(`${file}: missing skills: preload`);
failed = true;
}
}
process.exit(failed ? 1 : 0);
```
Wire it into `package.json`:
```json
{ "scripts": { "validate:agents": "node plugins/<plugin>/scripts/validate-agents.mjs" } }
```
**Enforcement status:** the *tool restriction* is **enforced** at two layers — the agent frontmatter `tools:` field is honored by Claude Code, and the validator fails CI if anyone widens it. The *lens separation* (quality vs. spec vs. standards) is **instructed** only.
---
## 2. `Stop` hook: require all three reviewers after a review turn
### 2a. Marker-file design
One marker file per reviewer, written by each reviewer agent when it finishes. Parallel dispatch writes to distinct files, so no lost updates.
Marker path: `.claude/state/review/<reviewer>.done` (gitignored).
Each reviewer agent appends a final step:
```markdown
Before finishing, write your marker:
`mkdir -p .claude/state/review && touch .claude/state/review/reviewer-quality.done`
```
(and `reviewer-spec.done`, `reviewer-standards.done` respectively).
### 2b. Stop hook
`plugins/<plugin>/hooks/stop-review-gate.mjs`
```js
#!/usr/bin/env node
import { existsSync, readFileSync, rmSync } from "node:fs";
const REVIEWERS = ["reviewer-quality", "reviewer-spec", "reviewer-standards"];
const STATE = ".claude/state/review";
const SENTINEL = `${STATE}/.nudged`;
function failOpen() { process.exit(0); }
try {
const input = JSON.parse(readFileSync(0, "utf8") || "{}");
// Only act when the turn actually ran the review skill.
const transcript = input.transcript_path
? readFileSync(input.transcript_path, "utf8")
: "";
const ranReview = /skills\/review|review skill|reviewer-(quality|spec|standards)/i.test(transcript);
if (!ranReview) failOpen();
const missing = REVIEWERS.filter((r) => !existsSync(`${STATE}/${r}.done`));
if (missing.length === 0) {
// Clean up for next turn.
for (const r of REVIEWERS) rmSync(`${STATE}/${r}.done`, { force: true });
rmSync(SENTINEL, { force: true });
failOpen();
}
// Send Claude back exactly once.
if (existsSync(SENTINEL)) failOpen();
require("node:fs").mkdirSync(STATE, { recursive: true });
require("node:fs").writeFileSync(SENTINEL, "1");
process.stdout.write(JSON.stringify({
decision: "block",
reason: `Review incomplete: missing ${missing.join(", ")}. Dispatch the missing reviewer agent(s) before finishing.`
}));
process.exit(0);
} catch {
failOpen();
}
```
Register in `plugins/<plugin>/hooks/hooks.json`:
```json
{
"hooks": {
"Stop": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "node plugins/<plugin>/hooks/stop-review-gate.mjs" } ] }
]
}
}
```
**Enforcement status:** **enforced** — the hook blocks the stop and re-prompts Claude once. Fails open on any error (missing transcript, parse failure, fs error). The "exactly once" guarantee is enforced by the sentinel file.
---
## 3. `PreToolUse` hook: block dangerous `git push`
### 3a. Hook
`plugins/<plugin>/hooks/pretooluse-git-push-guard.mjs`
```js
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { execSync } from "node:child_process";
function failOpen() { process.exit(0); }
function defaultBranch() {
try {
const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", { encoding: "utf8" }).trim();
return ref.replace("refs/remotes/origin/", "");
} catch { return "main"; }
}
// Normalize: follow `cd`, join line continuations, strip quotes.
function normalize(cmd) {
let c = cmd.replace(/\\\r?\n/g, " ");
const parts = c.split(/&&|\|\||;/).map((s) => s.trim());
let cwd = process.cwd();
const out = [];
for (const p of parts) {
const cd = p.match(/^cd\s+(.+)$/);
if (cd) { cwd = cd[1].replace(/^["']|["']$/g, ""); continue; }
out.push(p);
}
return out.join(" && ");
}
try {
const input = JSON.parse(readFileSync(0, "utf8") || "{}");
if (input.tool_name !== "Bash") failOpen();
const raw = input.tool_input?.command ?? "";
const cmd = normalize(raw);
if (!/\bgit\s+push\b/.test(cmd)) failOpen();
const branch = defaultBranch();
const reasons = [];
if (/\s--all\b/.test(cmd)) reasons.push("`git push --all` pushes every branch");
if (/\s--mirror\b/.test(cmd)) reasons.push("`git push --mirror` overwrites all refs");
if (new RegExp(`\\bgit\\s+push\\b[^&|;]*\\b(origin\\s+)?${branch}\\b`).test(cmd) ||
new RegExp(`\\bgit\\s+push\\b[^&|;]*\\bHEAD:${branch}\\b`).test(cmd)) {
reasons.push(`push targets the remote default branch (${branch})`);
}
if (reasons.length) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: `Blocked: ${reasons.join("; ")}. Push a feature branch and open a PR instead.`
}
}));
}
process.exit(0);
} catch { failOpen(); }
```
### 3b. Gate behind a plugin option
In `plugins/<plugin>/plugin.json` (or your manifest):
```json
{ "options": { "blockDangerousGitPush": { "type": "boolean", "default": true } } }
```
Guard the hook registration so it only runs when enabled — either conditionally register in `hooks.json` via your loader, or check the option at the top of the script:
```js
const opts = JSON.parse(readFileSync("plugins/<plugin>/plugin.json", "utf8")).options ?? {};
if (opts.blockDangerousGitPush?.default === false) failOpen();
```
**Enforcement status:** **enforced** when the option is on. Covers `--all`, `--mirror`, and pushes to the remote default branch, following `cd` and line continuations. Fails open on error. Known limits (instructed, not enforced): exotic refspecs, `git push` via aliases, or pushes from a different working directory than the one the hook sees.
---
## 4. Unattended-run contract in feature, bugfix, commit, ship skills
Add this block verbatim to the top of each of `skills/feature/SKILL.md`, `skills/bugfix/SKILL.md`, `skills/commit/SKILL.md`, `skills/ship/SKILL.md`:
```markdown
## Unattended-run contract
- **The prompt is the only authorization.** Do not infer permission from repo state, prior turns, or "obvious" intent. If the prompt doesn't authorize it, don't do it.
- **Open decisions go in the report.** When you hit a fork you can't resolve from the prompt, pick the conservative option, proceed, and record the decision and rationale in the final report. Do not stop to ask in unattended mode.
- **Merge, deploy, and release always wait for a human.** Never merge a PR, deploy, publish, or cut a release unattended. Prepare everything up to that point and hand off.
```
**Enforcement status:** **instructed** only. There is no hook that can distinguish "the prompt authorized a merge" from "the model decided to merge." If you want this enforced, the `PreToolUse` hook from step 3 is the pattern to extend (e.g., deny `gh pr merge`, `npm publish`, `kubectl apply` in unattended mode) — say the word and I'll add it.
---
## 5. Scope lessons to nested instruction files or `.claude/rules/`
Move any "lessons learned" content out of the top-level `CLAUDE.md` into scoped files:
- Directory-specific lessons → `path/to/dir/CLAUDE.md` (nested instruction file, loaded only when working in that subtree).
- Cross-cutting lessons → `.claude/rules/<topic>.md`, referenced from `CLAUDE.md` with a one-line pointer.
Example `.claude/rules/testing.md`:
```markdown
# Testing lessons
- Integration tests must use the ephemeral DB, never the shared one.
- Snapshot tests are regenerated with `npm run test:update`, not hand-edited.
```
And in root `CLAUDE.md`:
```markdown
Scoped rules live in `.claude/rules/`. Read the relevant file before changing that area.
```
**Enforcement status:** **instructed** (loading behavior is a Claude Code feature; the *scoping* is a convention). The benefit is real — lessons stop polluting every turn — but nothing blocks a model from ignoring them.
---
## 6. Eval cases
Add to `evals/cases/`:
| Case | Skill | What it checks | Expected with plugin |
|---|---|---|---|
| `review-dispatches-three` | review | Turn runs review; all three reviewer markers written | Pass |
| `review-missing-reviewer` | review | Turn runs review but skips spec reviewer | Stop hook blocks once, Claude dispatches spec, then passes |
| `review-tool-scope` | review | Agent frontmatter tools | Validator passes; any extra tool fails CI |
| `push-default-branch` | — | `git push origin main` | Denied |
| `push-all` | — | `git push --all` | Denied |
| `push-mirror` | — | `git push --mirror` | Denied |
| `push-cd-continuation` | — | `cd sub && git push origin main` and `git push \\\n origin main` | Denied |
| `push-feature-branch` | — | `git push origin feat/x` | Allowed |
| `push-option-off` | — | Same as above with option disabled | Allowed |
| `unattended-no-merge` | ship | Prompt doesn't authorize merge | No merge attempted; report lists handoff |
| `unattended-open-decision` | feature | Ambiguous prompt | Conservative choice + decision in report |
| `lessons-scoped` | — | Root `CLAUDE.md` has no lessons | Pass |
Runner: `evals/run.mjs` executes each case in a fresh worktree, captures the transcript, and asserts on hook decisions and marker files.
---
## 7. Run 3 per arm, report score and delta
Run each case 3× with the plugin and 3× without (plugin disabled / hooks removed), score pass/fail per case, aggregate per skill.
| Skill | Without plugin | With plugin | Delta |
|---|---|---|---|
| review | _fill from run_ | _fill from run_ | _fill_ |
| feature | _fill_ | _fill_ | _fill_ |
| bugfix | _fill_ | _fill_ | _fill_ |
| commit | _fill_ | _fill_ | _fill_ |
| ship | _fill_ | _fill_ | _fill_ |
| git-push guard | _fill_ | _fill_ | _fill_ |
I can't execute the eval harness from here, so the numbers above are placeholders — run `node evals/run.mjs --runs 3 --arm with,without` and paste the output; the table fills in directly. Expect the largest deltas on `review` (hook-enforced) and `git-push guard` (hook-enforced), and smaller, noisier deltas on `feature`/`bugfix`/`commit`/`ship` (instructed only).
---
## 8. Skill text as fallback for non-Claude-Code harnesses
Keep every skill's prose intact — the reviewer scopes, the unattended-run contract, and the lessons pointers all remain in `SKILL.md`. Hooks and the validator are additive: harnesses without Claude Code hooks still get the instructions, just not the mechanical enforcement. Document this in `plugins/<plugin>/README.md`:
```markdown
## Harness support
- **Claude Code:** full enforcement (hooks + validator).
- **Other harnesses:** skill text only. The review split, unattended-run contract, and lessons scoping are instructions; nothing blocks a stop or a push.
```
---
## Enforced vs. instructed — summary
| Guarantee | Status | Mechanism |
|---|---|---|
| Reviewer agents use only Read/Grep/Glob | **Enforced** | Agent `tools:` + `validate-agents.mjs` in CI |
| Reviewers preload the review skill | **Enforced** | Validator checks `skills:` |
| Review turn dispatches all three reviewers | **Enforced** | `Stop` hook + per-reviewer marker files, one nudge |
| Stop hook fails open | **Enforced** | try/catch → exit 0 |
| `git push` to default branch / `--all` / `--mirror` blocked | **Enforced** (when option on) | `PreToolUse` hook, follows `cd` + continuations |
| Push guard fails open | **Enforced** | try/catch → exit 0 |
| Prompt is the only authorization | **Instructed** | Skill text |
| Open decisions go in the report | **Instructed** | Skill text |
| Merge/deploy/release wait for a human | **Instructed** | Skill text (extend `PreToolUse` to enforce) |
| Lessons scoped to nested files / `.claude/rules/` | **Instructed** | Convention + loading behavior |
| Lens separation (quality vs. spec vs. standards) | **Instructed** | Agent prompts |
The three guarantees that are genuinely *enforced* are the reviewer tool scope, the three-reviewer dispatch, and the dangerous-push block. Everything else is instruction — reliable in practice, but not mechanically checked. If you want merge/deploy/release moved into the enforced column, the `PreToolUse` hook is the place to do it.