| name | council-patterns |
| description | Canonical reference for yellow-council reviewer contracts, CLI invocation, redaction, and output-parsing conventions. Use when authoring or modifying claude-reviewer, gemini-reviewer, opencode-reviewer, or the /council command. |
| user-invocable | false |
council-patterns Skill
What It Does
Single source of truth for yellow-council reviewer surfaces. Defines:
- Per-mode pack templates (plan / review / debug / question)
- Reviewer output schema (verdict / confidence / findings / summary)
- 11-pattern credential redaction awk block
- Injection fence format
timeout invocation pattern with exit code handling
- Path validation rules
- Slug derivation algorithm with collision handling
- Diff truncation algorithm for
review mode
- UNKNOWN verdict fallback semantics
- Atomic file write convention (Write tool direct, brainstorm-orchestrator pattern)
Reviewer agents (claude-reviewer.md, gemini-reviewer.md,
opencode-reviewer.md) and the /council orchestrator command read this skill
at agent spawn time via skills: frontmatter preload.
claude-reviewer is the in-process slot — no Bash, no CLI to wrap. See
"Claude slot" under Reviewer-Specific CLI Flag Pattern below for what that
makes N/A. What it DOES share with the other three: the Layer-2 6-key return
contract, the verdict enum and UNKNOWN fallback, the findings cap, the
injection fence format, and the redaction pattern list.
When to Use
- Authoring
claude-reviewer.md, gemini-reviewer.md, or
opencode-reviewer.md
- Authoring
commands/council/council.md
- Modifying any of the above — keep contracts in sync via this single source
Usage
Per-Mode Pack Templates
All four modes share a structural envelope. Only the ## Task block differs.
The {{REVIEWER_NAME}} slot is the only per-reviewer variable; templates are
otherwise identical across all four reviewers. (claude-reviewer's spawn
prompt carries one additional line — the orchestrator-minted fenced-output
path — because it has no Bash and cannot mint one itself. That line is
appended by council.md, not part of the pack template.)
You are {{REVIEWER_NAME}}, a code reviewer performing an INDEPENDENT analysis.
Do not reference what other reviewers might say. Only report findings you can
cite with a file:line reference. Do not write any files; analyze only.
## Task: {{MODE}}
{{MODE_SPECIFIC_CONTEXT}}
## Required Output Format
Verdict: APPROVE | REVISE | REJECT
Confidence: HIGH | MEDIUM | LOW
Findings:
- [P1|P2|P3] file:line — <80-char summary>
Evidence: "<exact quoted line from file>"
[repeat per finding; if none: write "Findings: none"]
Summary: <2-3 sentences in your own words>
## Rules
- P1 = security/correctness blocker; P2 = quality issue; P3 = style/nit
- Cite file paths relative to repository root
- If a finding has no quotable line (e.g., "missing function"), write `Evidence: N/A — <reason>`
- The `Verdict:` line is required and must appear exactly as shown
Per-mode {{MODE_SPECIFIC_CONTEXT}} block:
| Mode | Context block contents |
|---|
plan | ### Planning Document + fenced full content + ### Repo Conventions + truncated CLAUDE.md (capped at 4K chars) |
review | ### Diff (HEAD vs <BASE_REF>) + fenced git diff output (truncated per algorithm below) + ### Changed Files + truncated content of each (4K chars per file) |
debug | ### Symptom + user-supplied text + ### Cited Files + content of each --paths file (4K chars per file, max 3 files) + ### Recent History + git log -10 --oneline -- <paths> |
question | ### Question + user-supplied text + (optional) ### Referenced Files + content of each --paths file (4K chars per file, max 3 files) + ### Repo Conventions + truncated CLAUDE.md (4K chars) |
Reviewer Output Schema
Two distinct layers, easy to conflate:
Layer 1 — CLI output → reviewer agent (capitalized Verdict: format).
The external CLI's response to the pack uses the capitalized format the
pack template above demands (Verdict: / Confidence: / Findings: /
Summary:). Each reviewer AGENT parses that CLI output with these
regexes:
VERDICT=$(grep -m1 '^Verdict: ' "$OUTPUT_FILE" | sed 's/^Verdict: //')
CONFIDENCE=$(grep -m1 '^Confidence: ' "$OUTPUT_FILE" | sed 's/^Confidence: //')
SUMMARY=$(awk '/^Summary: / { sub(/^Summary: /, ""); print; exit }' "$OUTPUT_FILE")
FINDINGS=$(awk '/^Findings:/ { capture=1; next } /^Summary: / { capture=0 } capture' "$OUTPUT_FILE")
Layer 2 — reviewer agent → council (lowercase 6-key contract). After
parsing, redacting, and fencing, the agent's own Task-tool return carries
the structured 6-key contract that parse_reviewer_return in council.md
(the authoritative definition site) extracts uniformly for all four
reviewers: verdict= / confidence= / summary= / fenced_output_path=
plus the findings_block_begin...findings_block_end sentinel pair —
lowercase key= lines, first occurrence wins (grep -m1). The
capitalized Layer-1 lines never reach council.md directly. (Codex differs
only at Layer 1 — its CLI emits strict-mode JSON parsed with jq per
yellow-codex's codex-patterns skill; its Layer-2 return is identical.)
claude-reviewer also returns summary= and its findings block empty by
contract. The three CLI reviewers run the redaction inside their own agent
before returning, so their prose is sanitized by the time the orchestrator sees
it; the in-process slot has no Bash and cannot, and anything it returned would
enter orchestrator context raw, where no later pass can retract it. It writes
its prose only into its fenced file, and council.md reads the summary and
findings back out of that file after redacting it, using the Layer-1
regexes above. Verdict and confidence are still returned directly — both are
constrained to a fixed enum on arrival and carry no free text.
claude-reviewer has no Layer 1 at all — there is no external CLI whose
output it parses. It implements Layer 2 directly, and writes the capitalized
Verdict:/Confidence:/Findings:/Summary: shape only into its fenced
output file, so the report's raw-output appendix reads identically across all
four reviewers. This is the one contract asymmetry worth stating twice: the
pack it receives still contains the ## Required Output Format block demanding
capitalized keys, and an in-process reviewer that obeys that block instead of
the Layer-2 contract returns nothing parse_reviewer_return can match — its
slot is then silently recorded as ERROR on every run.
If the CLI output's Verdict: line is absent, the reviewer agent must:
- Set
VERDICT=UNKNOWN, CONFIDENCE=LOW
- Use the first 2K chars of the raw output as
SUMMARY (truncated at word boundary)
- Set
FINDINGS= (empty — cannot extract structured findings without a parseable verdict)
- Surface a one-line warning to council.md:
"[<reviewer>] Warning: no Verdict: line found in output — marked UNKNOWN"
UNKNOWN verdicts are excluded from the synthesis Headline majority computation
but are included in the Disagreement section so the user sees the prose.
11-Pattern Credential Redaction
Apply this awk block to all reviewer output BEFORE injection fencing and
BEFORE writing to docs/council/<file>.md:
function strip_deco(s, prev, guard, limit) {
# Strip to a FIXPOINT rather than in one fixed pass. Decoration nests in
# arbitrary order and depth: a blockquote inside a list item
# ("- > <header>"), a combined diff with one prefix character per parent
# ("++"/"--"), a numbered excerpt wrapping either. A single ordered pass
# removes whichever layer it happens to reach first and leaves the rest, so
# the marker never normalises, the anchored classifier fails, and the block
# drops to the bounded path where a narrowly wrapped body leaks.
#
# Repeating until nothing changes removes every layer regardless of order
# or count. The bound is derived from the INPUT LENGTH, not a constant: an
# iteration only continues after removing at least one character, so
# length(s)+2 iterations always reach the fixpoint. A CONSTANT ceiling (the
# original 8, then 64) is a real limit on a nesting depth the attacker
# chooses -- 100 leading "+" exhausted the 64-ceiling with prefixes still
# attached, the anchored classifier below then failed, and the block leaked
# on the bounded path.
#
# Reaching `limit` is therefore impossible while every substitution above
# shrinks s; it can only mean a later edit added one that rewrites without
# shrinking. That is a bug, not deep nesting, so record it and let the
# caller fail CLOSED (treat the line as a real key) instead of falling
# through to the bounded path. No test exercises this arm today -- it exists
# so a future edit degrades safely rather than silently leaking.
# A "+" run is consumed whole below, so the common flood case is linear. A
# long "-" run still costs one pass per character (the delimiter guard has to
# re-test after each removal), which is quadratic in the run length. An
# earlier revision bounded that with a flat length cap that failed CLOSED,
# but keying "this is a real key" off LENGTH ALONE meant any long line that
# merely MENTIONED a marker was promoted to a real key and swallowed the
# report through EOF. Cost is bounded here only for the "+" case; a hostile
# "-" flood is a known open issue, tracked rather than papered over with a
# guard that misclassifies.
guard = 0
limit = length(s) + 2
do {
prev = s
sub(/^[[:space:]]*([>|][[:space:]]*)*/, "", s)
sub(/^([-*+]|[0-9]+[.)])[[:space:]]+/, "", s)
sub(/^[0-9]+[[:space:]]*\|[[:space:]]*/, "", s)
# A "+" run can never be part of a PEM delimiter, so take the whole run in
# one pass. Only the dash case below needs character-at-a-time care.
sub(/^\+\+*/, "", s)
# Never strip a leading dash off a line that is ALREADY a valid PEM
# delimiter: that corrupts "-----BEGIN" into "----BEGIN" and breaks every
# anchored test downstream.
if (s !~ /^-----BEGIN/ && s !~ /^-----END/) sub(/^[-+]/, "", s)
sub(/^[[:space:]]+/, "", s)
} while (s != prev && ++guard < limit)
deco_exhausted = (s != prev)
sub(/[[:space:]]+$/, "", s)
return s
}
function cred_hit(re, minlen, s) {
# mawk (the default /usr/bin/awk on Debian/Ubuntu) does not support
# interval expressions ({n,}/{n}) — it matches them literally, so a
# `{20,}`-gated credential regex silently stops matching real secrets on
# a mawk host. match()+RLENGTH (POSIX, mawk-safe) reproduces the same
# trigger condition without interval syntax: `+` greedily consumes the
# run after the literal prefix, RLENGTH is prefix-plus-run length, so
# RLENGTH >= prefixlen+N is equivalent to {N,} / {N} for detection
# purposes (we only ever discard the matched text, never reuse it, so
# {N} exact and {N,} at-least are interchangeable here).
# match() returns only the LEFTMOST occurrence. When a short placeholder
# sharing the same literal prefix appears before a real token on the same
# line ("example sk-ant-xxx ... sk-ant-<real>"), the leftmost RLENGTH falls
# under minlen and the line — real token included — is emitted unredacted.
# Walk every start position instead of testing only the first, advancing by
# ONE character rather than past the whole match: a longer occurrence can
# begin inside a shorter one ("sk-sk-ant-<real>"), and skipping RLENGTH
# would step over it.
s = $0
while (match(s, re)) {
if (RLENGTH >= minlen) return 1
s = substr(s, RSTART + 1)
}
return 0
}
function is_base64_line(s, minlen) {
if (s !~ /^[A-Za-z0-9+\/=]+$/) return 0
return length(s) >= minlen
}
{
line = $0
# OpenAI / Anthropic / Google / GitHub / AWS / Bearer / Authorization
if (cred_hit("sk-proj-[A-Za-z0-9_-]+", 28)) line = "--- redacted credential at line " NR " ---"
else if (cred_hit("sk-ant-[A-Za-z0-9_-]+", 27)) line = "--- redacted credential at line " NR " ---"
else if (cred_hit("sk-[A-Za-z0-9]+", 23)) line = "--- redacted credential at line " NR " ---"
else if (cred_hit("AIza[0-9A-Za-z_-]+", 39)) line = "--- redacted credential at line " NR " ---"
else if (cred_hit("gh[pous]_[A-Za-z0-9]+", 40)) line = "--- redacted credential at line " NR " ---"
else if (cred_hit("github_pat_[A-Za-z0-9_]+", 51)) line = "--- redacted credential at line " NR " ---"
else if (cred_hit("AKIA[0-9A-Z]+", 20)) line = "--- redacted credential at line " NR " ---"
else if (cred_hit("Bearer [A-Za-z0-9._~+\\/-]+", 27)) line = "--- redacted credential at line " NR " ---"
else if (cred_hit("Authorization: [A-Za-z0-9 ._~+\\/-]+", 35)) line = "--- redacted credential at line " NR " ---"
else if (cred_hit("ses_[A-Za-z0-9]+", 20)) line = "--- redacted credential at line " NR " ---"
# PEM private key block — multi-line state machine.
# NOTE: test the ORIGINAL line ($0) for BEGIN/END so the redaction-replacement
# of `line` does not blind the END check (otherwise in_pem never resets).
# UNANCHORED substring match on purpose: a full-line anchor
# (^...[[:space:]]*$) lets a key flattened onto one line — or quoted
# inline in prose ("leaked key: -----BEGIN PRIVATE KEY----- MII…") —
# bypass redaction entirely because the BEGIN marker never matches.
# `[A-Z ]*` not `[A-Z ]+`, so the bare PKCS#8 header (-----BEGIN PRIVATE
# KEY-----, no algorithm word) matches as well.
#
# The END test below anchors the TAIL only ([[:space:]]*$), never a
# full-line ^...$ anchor — do NOT "fix" this by anchoring the start too,
# that reintroduces the exact bypass documented in
# docs/solutions/security-issues/awk-pem-state-machine-variable-mutation.md.
# A leading prefix (numbered excerpt, blockquote, JSON key) still matches
# because there is no ^ anchor; only trailing content after the marker is
# rejected.
#
# SCOPE: everything above is about ENTERING and LEAVING pem mode, which is
# deliberately unanchored so no marker shape can dodge redaction. It is NOT
# about the real-vs-prose classifier further below, which anchors
# `pem_check` with `^...$` on purpose. The two are separate decisions and
# must not be "made consistent": unanchoring entry keeps keys from escaping,
# while anchoring the classifier keeps ordinary prose that merely ends by
# quoting a header from being read as a real key and redacting the report to
# EOF. Decoration is stripped before the classifier runs, so a diff- or
# blockquote-prefixed real marker still reaches it anchored.