From spec-review
Iteratively review a spec at `docs/superpowers/specs/<...>.md` via a fresh-context subagent loop. Apply catches you agree with, push back on weak ones, commit each round, stop on a clean verdict or a safety cap. Invoke after `brainstorming` writes a fresh spec, or on demand via `/spec-review [path]`.
How this skill is triggered — by the user, by Claude, or both
Slash command
/spec-review:spec-reviewThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
- Right after the `brainstorming` skill writes a new spec under `docs/superpowers/specs/`.
brainstorming skill writes a new spec under docs/superpowers/specs/./spec-review (with or without a path argument).Do NOT use this skill to review code, plans, or non-spec markdown. The reviewer prompt is tuned for the spec genre.
Read the spec argument the skill was invoked with. The slash command forwards $ARGUMENTS verbatim; it is a single string with no tokenization.
Trim surrounding whitespace.
If the trimmed string is non-empty, treat it as a single path:
Status: resolve-failed with Error: spec not found: <path> (see the "Render the final report" section) and stop.Status: resolve-failed with Error: spec path must be a file: <path> and stop.If the trimmed string is empty (no argument):
Glob tool with pattern docs/superpowers/specs/**/*.md from the current working directory.Glob returns paths sorted by modification time. Assume newest-first; take the first result. If the first result looks older than other results (sanity check via the timestamps shown alongside the Glob output), reverse and take the last.Glob returns zero results → emit Status: resolve-failed with Error: no specs found under <cwd>/docs/superpowers/specs/; pass a path explicitly and stop.Emit the resolved path to the user (your assistant-turn output) as a markdown inline-code line so backslashes and underscores don't get mangled:
Resolved spec: \``
Continue to the git precondition checks.
All git operations target the repository containing the spec, not necessarily the parent's cwd. Compute repo = git -C <dirname(spec_path)> rev-parse --show-toplevel and cache it. Use git -C "<repo>" ... everywhere below.
Run these checks in order; on any failure, emit Status: precondition-failed with the corresponding Error: ... message and stop:
In a git repo: if git -C <dirname(spec_path)> rev-parse --show-toplevel fails → Error: spec is not inside a git repository; per-round commits cannot be created.
Branch checked out: if git -C <repo> rev-parse --abbrev-ref HEAD returns HEAD (detached state) → Error: spec's repository is in detached-HEAD state; check out a branch before running /spec-review.
No in-progress operation: for each of rebase-merge, rebase-apply, MERGE_HEAD, CHERRY_PICK_HEAD, check whether git -C <repo> rev-parse --git-path <name> resolves to an existing path. If any does → Error: spec's repository has an in-progress rebase/merge/cherry-pick; finish or abort it first.
Spec is clean: if git -C <repo> diff --quiet -- <spec_path> fails (working-tree diff) OR git -C <repo> diff --quiet --cached -- <spec_path> fails (index diff) → Error: spec has uncommitted changes; commit, stash, or — if the diff is only line-endings on a fresh clone — run "git add --renormalize <spec>". The per-round commit trail needs the spec at a known baseline.
No unrelated staged changes: if git -C <repo> diff --cached --name-only lists any file other than <spec_path> (relative to repo root) → Error: unrelated staged changes present; commit or stash them before running /spec-review.
Unstaged changes to files other than the spec are allowed. The parent only stages the spec file.
After preconditions pass, capture start_sha = git -C <repo> rev-parse HEAD (a baseline for the final report's Commits range), and compute topic from the spec filename per the "Topic derivation" subsection below.
topic is used in commit messages as spec(<topic>): ....
.md extension).YYYY-MM-DD- (four digits, dash, two digits, dash, two digits, dash), strip that prefix.Examples:
2026-05-16-spec-review-skill-design.md → spec-review-skill-designFeature Plan v2.md → feature-plan-v22026-05-16.md → 2026-05-16 (fallback)These four lists plus the scalars persist for one invocation of the skill. Re-initialize to empty/zero at the start of every invocation; do not carry state across invocations.
applied — records of {severity, brief_description}. Each successful per-catch edit appends one entry.disagreements — records of {catch, reasoning}. Catches the parent disputed (or accepted-but-could-not-locate on the non-rebuttal path). The bucket is internally named disagreements but surfaces externally as Disputed in the report.stale — full catch records whose Edit failed because a within-round prior edit clobbered the anchor, OR compelling-rebuttal-with-INVALID-ANCHOR cases.notes — free-form one-line strings (zero-match rebuttal removals, weak-rebuttal dismissals, stray-REBUTTAL prefixes, hook-rewrote-spec detection, etc.).round = 1MAX_ROUNDS = 5 (soft cap — keep this value here as the single source of truth)HARD_CAP = 20 (hard cap — never exceed)start_sha (captured above)topic (computed above)spec_path (the absolute path)repo (the git toplevel containing the spec)Repeat until a stop condition fires.
Read the template at ./reviewer-prompt.md — Claude Code resolves paths beginning with ./ relative to this skill's installed directory, where SKILL.md and reviewer-prompt.md live side-by-side.
Substitute <SPEC_PATH> with the absolute spec_path.
Handle the <PRIOR_ROUNDS_BLOCK> / </PRIOR_ROUNDS_BLOCK> block:
</PRIOR_ROUNDS_BLOCK> used to be — it is the visual separator between the rendered content and the verdict instruction.Rendered scaffolding (round 2+), with sub-list bullets two-space-indented under each label:
In prior rounds:
- Applied:
- [<severity>] <brief_description>
- ... (or "(none)" if `applied` is empty)
- Disagreed-with by the spec author, with their reasoning:
- [<catch.severity>] <catch.title> — reasoning: <reasoning>
- ... (or "(none)")
- Could not be located in the current spec (the parent could not
mechanically apply these earlier; you may re-raise with a fresh
anchor quote if they still apply):
- [<catch.severity>] <prefix><catch.title>; was anchored to: "<catch.where>"; intent: <one-line summary>
- ... (or "(none)")
For the stale sub-list, <prefix> is the literal REBUTTAL: when the original catch carried that prefix, empty otherwise. Embedded double quotes in <catch.where> are passed through verbatim.
Pre-dispatch sanity assertion: scan the rendered prompt for the literal strings <PRIOR_ROUNDS_BLOCK>, </PRIOR_ROUNDS_BLOCK>, <SPEC_PATH>. If any is present, this is a parent-side bug — emit Status: template-bug with Error: template substitution leaked block tags or the <SPEC_PATH> placeholder into the rendered prompt and stop.
Call the Agent tool with subagent_type=general-purpose and the rendered prompt. The general-purpose subagent already has Read; the Agent tool does not accept a per-call tool allowlist, so pass none.
If Agent itself raises (transport / platform failure), emit Status: dispatch-error with Error: <repr(error)> and stop.
The parser is strict but permissive in specific places:
Verdict line: the last non-empty line of the response, after trimming whitespace, must equal exactly VERDICT: NO_REMAINING_ISSUES or VERDICT: ISSUES_REMAIN. Anything else → MALFORMED.
Catches: scan all ### headings in document order. A heading is a catch iff its content matches <SEVERITY>. <short-id>: [REBUTTAL: ]<title> where <SEVERITY> is the literal word CRITICAL, IMPORTANT, or MINOR followed by a period and space, and <short-id> matches [CIM]\d+. Non-matching ### headings are ignored without error.
REBUTTAL: prefix: matched case-sensitively, immediately after <short-id>: , with exactly one space after the colon. Variants do not set is_rebuttal_prefixed; they remain part of the title.
Catch body: for each catch heading, capture where from a **Where:** ... line, whats_wrong from **What's wrong:** ..., address from **Address:** .... Missing lines yield empty strings. At least one of the three must be non-empty for the heading to count as a catch; if all three are empty, the heading is silently skipped.
Severity-vs-short-id mismatch (e.g., ### IMPORTANT. M3: ...): the heading's severity word wins; record the short_id as-is.
Contradictory pair: if the verdict is ISSUES_REMAIN AND zero parseable catches were collected → MALFORMED.
If MALFORMED, retry once with a correction nudge. Construct a new prompt: the original rendered prompt, plus two newlines, plus:
A previous attempt at this review did not match the required format. Emit your review in the exact format described above; in particular, the LAST non-empty line must be exactly 'VERDICT: NO_REMAINING_ISSUES' or 'VERDICT: ISSUES_REMAIN'.
Dispatch again. If the retry also dispatches successfully but parses as MALFORMED → emit Status: malformed and stop. If the retry's Agent.dispatch raises → Status: dispatch-error.
If verdict is NO_REMAINING_ISSUES:
catches is non-empty, append to notes: clean verdict accompanied by N catch(es), discarded: <comma-joined short_ids>.Status: clean final report and stop.Snapshot D0 = list(disagreements) (the round-start state).
Snapshot S0 = read(spec_path) (the round-start spec content as a string).
For each catch in document order:
catch.is_rebuttal_prefixed AND disagreements == [], append to notes: catch <short_id>: REBUTTAL: prefix appeared with no current disagreements; treated as a new catch.catch.is_rebuttal = (catch.is_rebuttal_prefixed AND disagreements != []) OR overlaps_any(catch, disagreements).overlaps_any(catch, disagreements) is a judgment call you make by reading the catch's title and body against each disagreement's catch title and reasoning. True iff the catch substantially restates a prior disagreement, regardless of REBUTTAL prefix. Returns False on an empty list.
Initialize applied_this_round = [], disagreed_this_round = [], stale_this_round = [].
For each catch in document order:
If catch.is_rebuttal is True:
Decide rebuttal_is_compelling(catch, D0): True iff the rebuttal points to a NEW fact, a NEW consequence, or a logical flaw in the disagreement's reasoning — not merely re-asserting the original concern. Evaluate against the round-start snapshot D0 so sibling rebuttals earlier in the same round don't poison the check.
Compelling:
remove_matching_disagreement(catch, disagreements) — i.e., remove every entry from disagreements (mutating the live list, not D0) for which overlaps_any(catch, [d]) is True. Capture removed_count. If removed_count == 0, append to notes: catch <short_id>: compelling rebuttal of no current disagreement; treated as a fresh accepted catch.apply_edit(catch, S0) (see below). Branch on result:
OK → applied_this_round.append({severity: catch.severity, brief_description: brief_description(catch)}).STALE → stale_this_round.append(catch).INVALID_ANCHOR → stale_this_round.append(catch) AND append note: catch <short_id>: compelling rebuttal but anchor not located; routed to stale. (The parent already accepted the rebuttal logically; "could not apply" is a stale outcome, not a fresh disagreement.)Not compelling (weak rebuttal):
catch.is_rebuttal_prefixed: round <round>: rebuttal <short_id> (explicit prefix) was not compelling against the D0 disagreement(s).round <round>: catch <short_id> classified as implicit rebuttal by overlap; judged non-compelling against D0 and dropped.If catch.is_rebuttal is False:
Decide parent_judges_catch_correct_or_uncertain(catch):
True: call apply_edit(catch, S0):
OK → applied_this_round.append({severity: catch.severity, brief_description: brief_description(catch)}).STALE → stale_this_round.append(catch).INVALID_ANCHOR → disagreed_this_round.append({catch, reasoning: "could not locate the anchor cited by the reviewer; treating as could-not-address"}).False: disagreed_this_round.append({catch, reasoning: <your one- or two-sentence rationale>}).
After processing all catches:
applied += applied_this_rounddisagreements += disagreed_this_roundstale += stale_this_roundapply_edit(catch, S0)S0 if previous catches in this round have made edits).catch.where as the primary anchor, with catch.whats_wrong and catch.address as guides. The reviewer was asked to quote in where, but may have paraphrased — author your own old_string from the current spec content.Edit tool (or Write for whole-section rewrites).Edit succeeds → return OK.Edit fails:
old_string is a substring of S0.STALE (an earlier within-round edit clobbered the anchor).INVALID_ANCHOR (the anchor was never present at round start; the reviewer hallucinated or quoted with drift).brief_description(catch): a one-line summary in your voice (not the reviewer's wording), focused on the change you applied. Aim for ~100 characters. Example: "specified subagent reads spec via Read; spec path always absolute".
If applied_this_round is empty, skip this step.
Otherwise, run via Bash:
git -C "<repo>" add "<spec_path>"
git -C "<repo>" commit -m "spec(<topic>): round <round> revisions (applied <k>, disputed <m>, stale <s>)"
where k = len(applied_this_round), m = len(disagreed_this_round), s = len(stale_this_round). Note lowercase per-round letters; the final report uses uppercase cumulative K, M, S.
If the commit command fails (non-zero exit), emit Status: commit-failed with Error: <stderr or summary> and stop.
After a successful commit, check whether a hook rewrote the spec without re-staging: run git -C "<repo>" diff --quiet HEAD -- "<spec_path>". If it fails (i.e., there IS a diff), append a note: round <round>: a commit hook modified the spec but did not re-stage; subsequent rounds read the modified content from disk. (A hook that staged its rewrite produces no diff and needs no note.)
If catches is non-empty AND applied_this_round == [] AND stale_this_round == [], the round produced zero forward progress. Build a classifying note:
weak = count of catches with is_rebuttal == Truedisputed = len(disagreed_this_round)weak == len(catches): kind = all N catches were weak rebuttalsdisputed == len(catches): kind = all N catches were disputed as freshmixed: W weak rebuttals, D disputed-freshAppend stuck round <round>: <kind> to notes, then emit Status: stuck final report and stop.
round += 1.round > HARD_CAP → emit Status: hard-cap final report with round - 1 and stop.last_round_had_severity = any(catch.severity in {CRITICAL, IMPORTANT} for catch in catches).round > MAX_ROUNDS AND NOT last_round_had_severity → emit Status: cap-hit final report with round - 1 and stop.round == MAX_ROUNDS + 1 AND last_round_had_severity (one-time crossing) → append note: crossed soft cap (MAX_ROUNDS=5) because the previous round still surfaced CRITICAL/IMPORTANT catches; loop continues until either a clean/stuck/cap exit or HARD_CAP=20.Continue from Step A.
Emit (to the user's assistant-turn output) a fenced block of the following form. K = len(applied), M = len(disagreements), S = len(stale).
Spec: `<absolute spec path>`
Status: <status>
Rounds: <round-or-round-minus-one per Step J>
Applied (K = <K>):
- [<severity>] <brief_description>
- ...
(or `(none)` when K = 0)
Disputed (M = <M>):
- [<catch.severity>] <catch.title> — reasoning: <reasoning>
- ...
(or `(none)` when M = 0)
Stale (S = <S>):
- [<catch.severity>] <catch.title>
- ...
(or `(none)` when S = 0)
Notes:
- <note>
- ...
(omit this section entirely if `notes` is empty)
Commits:
- <sha> <commit-subject>
- ...
(or `(none)`)
Error (only when Status is one of: dispatch-error, commit-failed, template-bug, precondition-failed, resolve-failed):
<error message>
Field explanations:
Rounds: — the integer round argument. For clean and stuck exits, this is the current round (un-incremented, equal to the round whose work was just observed). For cap-hit and hard-cap exits, the loop passes round - 1 because round was incremented in Step J before the cap check, and the user's mental model is "the last round that ran" not "the round we never started."Render the Commits: section by running git -C "<repo>" log --reverse --format='%h %s' <start_sha>..HEAD -- "<spec_path>" and taking each output line as a bullet. If the command returns no lines, render (none).
On Status: commit-failed, also render this line immediately above Commits::
NOTE: round <round> edits are on disk but were not committed; the Applied (K) count includes them, but git log does not.
MAX_ROUNDS = 5 (soft cap, severity-aware — see Step J)HARD_CAP = 20 (hard cap, unconditional)To change either, edit this file. They are deliberately not runtime-configurable in v1.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub krzyssikora/claude-personal-plugin --plugin spec-review