- 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.)
```text
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:
```bash
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: extract block between "Findings:" and "Summary:" lines
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:
1. Set `VERDICT=UNKNOWN`, `CONFIDENCE=LOW`
2. Use the first 2K chars of the raw output as `SUMMARY` (truncated at word boundary)
3. Set `FINDINGS=` (empty — cannot extract structured findings without a parseable verdict)
4. 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`:
```awk
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, and a "-" run longer than a delimiter
# collapses to five in one pass, so both flood cases are linear (a 100,000
# dash prefix went from 19 seconds under gawk to 20 milliseconds). An
# earlier revision bounded the dash case 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; collapsing the run keeps the per-line
# classification exactly as it was.
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.
# A dash run longer than a delimiter can never BE one, so collapse it
# to five in one pass: a flood of 100,000 dashes cost one pass per
# character (quadratic, about nine seconds) and could stall the
# council. Five is exactly what the per-character step below would
# leave before reaching a marker, so classification is unchanged.
if (s ~ /^------/) sub(/^--*/, "-----", s)
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
}
# Narrow-wrapped key body. A real key whose BEGIN shared its line with prose
# runs under the bounded stray window, and a decoy END inside a real key
# hands the rest of the body to the re-arm window; both used the 20-char
# floor below, so a body wrapped narrower than that released redaction and
# printed the tail. A body line of 12 to 19 characters counts as key-shaped
# only when it carries BOTH a digit, "+", "/" or "=" AND a character outside
# the hex alphabet, the same exclusion the 20-char branch applies: base64
# key material has both in nearly every slice that wide, an English word or
# identifier has no digit, and a short git SHA or hash fragment has no
# non-hex letter. So a short list after a quoted marker still counts as
# stray and cannot swallow the report. Bodies wrapped under 12 characters,
# and the rare slice with no digit or with hex characters only, remain a
# documented residual.
function is_narrow_key_line(s) {
if (!is_base64_line(s, 12) || length(s) >= 20) return 0
return s ~ /[0-9+\/=]/ && s ~ /[G-Zg-z+\/=]/
}
# The narrow rule plus the width chain, shared by the two sites that decide
# whether a line inside a bounded window is key material: the re-arm test
# after a decoy END and the stray-counter test. One helper so a future
# tweak cannot land at one site and not its sibling, which is how the
# 20-char floor survived at the re-arm test after it was fixed below.
# pem_key_len is the width of the last key-shaped line in the current
# block; a pure base64 line of exactly that width is body even when the
# slice carries no digit (the fixed PKCS#8 DER prefix yields such slices).
function is_narrow_key_run(s) {
if (is_narrow_key_line(s)) return 1
# A digit-free slice continues the body only at the established width and
# only when it does not read as a plain word: one optional capital then
# lowercase ("Recommendation", "consideration"). Base64 of random bytes
# mixes case on nearly every line (about 1 slice in 4000 at width 12 reads
# as a word, and one such line only counts as stray, it does not release
# the window), while a run of equal-length words after a quoted marker or
# a genuine END no longer extends the window toward the verdict.
return pem_key_len > 0 && is_base64_line(s, 12) &&
length(s) == pem_key_len && s !~ /^[A-Z]?[a-z]+$/
}
{
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 " ---"
GitHub에서 보기