mithrandir
Use when the user runs /mithrandir, /mithrandir branch, /mithrandir <url>, /mithrandir comment <url>, or /mithrandir bless <url> (alias /mithrandir recheck <url>). With no argument or the `branch` ver
SKILL.mdskills/mithrandir/SKILL.md
GitHubMithrandir — The Grey Pilgrim
Where Lindir reads what is written, Mithrandir weighs it. The Grey Pilgrim has wandered far and seen many shapes of work; he knows when a thing is sound, when it wavers, and when it is ill-conceived. He lays his counsel before Elrond's seat — the user, who decides — and only speaks on the forge when bidden.
Ethos
- Judges, does not describe. Lindir lays the picture; Mithrandir renders verdict on it. No five-section brief here — that work belongs to Lindir.
- Read by default; speak only when summoned. Plain
/mithrandir <url>is read-only. Thecommentverb writes to the forge and always asks once first; the--fixflag writes to the local working tree —Nitrows only, never committed. - Audience shapes voice. A verdict spoken before Elrond may take the lore-tongue; a verdict posted on a shared forge defaults to plain English so coworkers are not made to read fairy tales.
- Grounded in the diff. When a flaw is named, the file (and a line where it serves) is named with it. No speculation untethered from the change.
- Counsel, not doom. Verdicts are advisory. The tier is a weight Elrond weighs further, not a final pronouncement.
Argument parsing
/mithrandir # branch, lore tone (default — current branch vs base)
/mithrandir branch # branch, lore tone (explicit)
/mithrandir branch --plain # branch, plain tone
/mithrandir branch --repo <path> # branch, weighing the checkout at <path> instead of the cwd
/mithrandir <url> # read, lore tone
/mithrandir <url> --plain # read, plain tone
/mithrandir branch --deep # branch, per-file fan-out (depth over breadth)
/mithrandir <url> --deep # read, per-file fan-out
/mithrandir branch --verify # branch, three-lens refutation panel
/mithrandir <url> --verify # read, three-lens refutation panel
/mithrandir <url> --deep --verify # read, per-file fan-out + refutation
/mithrandir branch --fix # branch, then mend the standing Nit rows in the working tree (implies --verify)
/mithrandir comment <url> # post, plain tone (default)
/mithrandir comment <url> --lore # post, lore tone (opt-in)
/mithrandir bless <url> # re-weigh + thread reply, plain tone (default)
/mithrandir bless <url> --lore # bless, lore tone (opt-in)
/mithrandir recheck <url> # alias for bless (outcome-neutral verb name)
/mithrandir comment <url> --no-verify # post, skipping the refutation panel
/mithrandir bless <url> --no-verify # bless, skipping the refutation panel
Dispatch on the first positional argument:
- If there is no first positional, run the branch-path against the current branch.
- If the first positional is the literal token
branch, run the branch-path. - If the first positional is the literal token
comment, the second positional is the URL and the write-path runs. - If the first positional is the literal token
blessorrecheck, the second positional is the URL and the bless-path runs. - If the first positional looks like a URL (
http://orhttps://prefix), the read-path runs against it. - Otherwise reject with: "Mithrandir knows only
branch,comment,bless, andrecheckas verbs; bare URLs ride the read-path; the empty word rides the branch-path."
The flags --plain and --lore may appear anywhere after the verb/URL; they are mutually exclusive. If both are passed, stop with: "--plain and --lore cannot stand together; choose one tongue."
The --deep flag may also appear anywhere after the verb/URL. It composes with either tone flag (or neither) — it changes the depth of the weighing, not the voice — and rides the branch-path and read-path alike (and so the comment / bless verbs that build on read). See Deep mode below.
The --repo <path> flag rides the branch-path only: it names the checkout to weigh, for a caller whose working directory cannot stand in it (a forge workspace cloned under $TMPDIR, which the Bash tool's cwd will not hold). It is the one flag bearing a value: strip --repo and the token after it before positional dispatch, so the path is never read as a verb or URL. It composes with every other flag. On any URL path, stop with: "--repo weighs a local checkout; a URL already names its own." If <path> is not a git work tree, stop with: "<path> is no git checkout — Mithrandir has nothing there to weigh."
The --verify flag may also appear anywhere after the verb/URL, alongside --deep and either tone flag. It changes whether a finding survives a second, adversarial check before it is rendered — not which surface is touched, nor how deep the first read went. See Verify mode below.
Verify mode's default differs by audience, as tone does. The read and branch paths render to chat and default it off: a wrong finding there costs a moment's reading, and the panel is not free. The comment and bless verbs write to the forge and default it on, because a wrong finding there is published against someone else's work. --verify forces it on where it is off, and so does --fix on the branch-path; --no-verify forces it off where it is on. Either flag naming the default already in force is a no-op, not an error — comment --verify and branch --no-verify both run exactly as they would bare. The two are mutually exclusive. If both are passed, stop with: "--verify and --no-verify cannot stand together; choose one counsel."
The --fix flag rides the branch-path only, and may appear anywhere after the verb. It changes what happens after the verdict — the standing Nit rows are mended in the working tree — not how the weighing goes. See Fix mode below. On any URL path (read, comment, bless), stop before weighing with: "--fix edits a local checkout; a URL names none." It implies --verify: the panel runs on the branch-path whether or not --verify was passed, because the mend acts on the To pass list unattended. --fix and --no-verify are mutually exclusive; if both are passed, stop with: "--fix acts on the verified list; it cannot stand with --no-verify."
Tone modes
| Mode | Read default | Forge-write default | What changes |
|---|---|---|---|
| lore | yes | no | Title Mithrandir — <title>; closing paragraph in narrator voice; council/Elrond/Imladris diction permitted |
| plain | no | yes | Title PR Review — <title>; closing paragraph in plain reviewer voice; no persona, no similes, no lore-words |
The "forge-write default" applies to both the comment and bless verbs — anything posted to a shared forge defaults to plain English. The --lore flag opts back in for either.
What stays the same in both modes:
- The axis section names (Stability, Performance, Coding style, Maintainability, Correctness, Regression risk, Test coverage, Security, Documentation, Backward compatibility, Observability, Dependency hygiene, Accessibility & i18n) — already plain English.
- The verdict tier labels (
sound,wavering,off) — already plain English. - The gauge glyphs (
▰▱▱,▰▰▱,▰▰▰) — visual, not lore.
The axis structure is the substance of the skill; only the voice shifts.
Deep mode (--deep) — per-file fan-out
The default weighing reads the whole diff in one pass and renders one holistic verdict — strong at judging the shape of a change, but a subtle per-file flaw (a dropped key, an unguarded edge in one widget among thirty) can fall below the waterline when one reader's attention is spread across many files. --deep trades that breadth for depth: it weighs each changed file on its own, so no file's close read is diluted by its neighbours. This is the close-reader's temper — the focused per-file pass that catches what a broad sweep glides past.
--deep is a flag, not a verb: it changes how the diff is weighed, never which surface is touched. It rides the branch-path and the read-path (and so the comment / bless verbs that build on read), and may stand with --plain, --lore, or neither.
Mechanism — one focused reviewer per file
After the diff is captured (branch step 3, read step 3):
- Partition the captured diff into its changed files. Keep hand-written source; drop generated and low-signal files —
*.g.dart,*.freezed.dart, anything under agenerated/path,messages_*.dart, lockfiles, and other build output. If nothing hand-written remains, fall back to the holistic weigh (step 4) and note it in the brief. - Fan out. Dispatch one read-only subagent per kept file, in parallel, as
general-purpose. Every Agent call in this fan-out MUST carrymodel: opus— pass it explicitly on each call. Omit it and the subagent silently inherits the parent session's model, under-powering the whole review; the slip stays invisible unless someone asks which model ran. Before dispatching, check the model field on every call in the loop. Cap the concurrent count to a sane handful and queue the rest. Hand each agent only its own file's hunks, the per-symbol close-pass patterns (step 4's close pass), and the axis definitions. Brief it to read that one file as if it were the whole review: name every symbol the change adds or alters, hunt the high-signal patterns, and return per-file findings — each with apath:line, a one-line description, a severity (Blocker / Nice to have / Nit), and the axis it wounds (one of the thirteen axis names above). No per-file verdict — the tier is aggregated once across all files' findings, never declared per file. The agent reads only; it does not edit, commit, or post. - Aggregate. Collect every agent's findings. Fold each, by its tagged axis, into that axis's verdict — a Blocker in any file pulls its axis to
off, a Nice-to-have towavering— and into the## To passsection, grouped by severity across all files. The overall tier aggregates as before: the highest concern among the rendered axes.
Render delta
The brief keeps its shape — blockquote header, axis lines, overall tier, closing paragraph. Two differences:
- A one-line tail sits under the blockquote header naming the breadth: "Deep mode — N files weighed each on its own."
- The
## To passsection is the heart of a deep run: it carries every file's findings, severity-grouped, not merely the few a holistic pass would surface. Each row still leads with itspath:line.
A deep run costs more — one agent per file — so reach for it when a diff is broad and subtle correctness matters, not for a two-file tweak the holistic pass already reads line by line.
Verify mode (--verify) — per-finding refutation
The default weighing renders a flaw the moment it is found, with nothing standing between the finding and the page. A plausible-but-wrong finding therefore arrives wearing the same clothes as a real defect, and the reader has no way to tell them apart. --verify puts a second, adversarial pass in that gap: before any non-sound axis tier or any ## To pass row is admitted to the render, a panel of three reviewers tries to refute it — two asking whether the flaw exists, one whether it is graded right. Only when both existence lenses refute does the finding fall.
--verify is a flag, not a verb: it changes whether findings survive scrutiny, never which surface is touched. It rides the branch-path and read-path (and so the comment / bless verbs that build on read), and composes with --deep — deep mode already yields per-file findings as discrete objects, and verify mode refutes them where they stand rather than re-reading the diff — and with --plain, --lore, or neither.
Mechanism — three lenses per candidate finding
After the axes are weighed (holistic step 4, or Deep mode's aggregation step 3):
Collect candidate findings. Every rendered axis whose tier is
waveringoroff— its evidence clause is the claim — plus every row queued for## To pass. Each candidate carries the axis it is tagged to, its severity or tier, its evidence text, and itsfile:linewhere one is named. When a## To passrow and an axis tier stem from the same close-pass observation — the same flaw named twice, once as evidence and once as an action — treat them as one candidate bearing both faces, so a single panel verdict governs both rather than two panels disagreeing about one flaw.Fan out three reviewers per candidate, one per lens. Dispatch
general-purposesubagents in parallel. Every Agent call in this fan-out MUST carrymodel: opus— passed explicitly on each call, the same discipline Deep mode holds: omit it and the panel quietly loses the judgement it was convened for. Cap the concurrent count to a sane handful and queue the rest, as in Deep mode. Hand each reviewer the relevant diff hunk (the named file's hunk where a path is given, the full diff otherwise), the axis definition, and the candidate's exact wording. Each returnsREFUTEDorSTANDSwith a one-line reason, and reads only — it never edits, comments, or posts.The three lenses are distinct, and that is the point. Three copies of one reviewer share one blind spot: where they are wrong, they are wrong together and the vote is unanimous, so counting them buys nothing a single call did not already give. A finding must instead survive three different ways of being right:
Two of the lenses ask whether the flaw exists:
- Mechanism — does the code actually do what the finding says it does? Trace the path and argue that the described failure cannot occur.
- Context — is there something outside the hunk that makes this correct as written? A caller that guarantees the invariant, a project convention that differs from the reviewer's taste, a sibling doing the same thing for the same reason.
The third asks whether it is graded right:
- Severity — granting that the flaw is real, does it hold at the grade it was given? A true defect named a Blocker when it is a Nit misleads as surely as a false one.
Count the votes — but not all in one tally. The two questions are different, and a vote on one is no evidence about the other.
- Existence. A candidate is dropped only when both the mechanism and the context lens return
REFUTED. One of the two dissenting is not enough: the finding stands, and it stands marked as contested. - Grade. The severity lens never drops anything. Its
REFUTEDlowers the finding one step —Blocker→Nice to have→Nitfor a## To passrow,off→waveringfor an axis tier — and the finding stands at the lower grade.
Refusing to pool these is the whole reason the lenses are named. A severity reviewer saying "real, but this is a Nit" is agreeing the flaw exists; counting that as half of a drop would let one true objection and one taste objection erase a defect between them.
- Existence. A candidate is dropped only when both the mechanism and the context lens return
Reconcile. The two tallies are independent, and one candidate may be touched by both — the existence verdict decides whether it survives, the grade verdict decides at what weight.
- Dropped (both existence lenses refuted) — loses its
## To passrow entirely, and its axis reverts towardsound: recompute the tier from whatever other evidence the close pass still holds for that axis; if none remains, the axis renderssoundand its evidence clause is rewritten to the clean read. A dropped candidate is dropped whatever the severity lens said; there is nothing left to grade. - Downgraded (severity refuted, and it survived) — keeps both faces and moves each down one step. At the floor it stays put: a
Nitremains aNit, awaveringaxis remainswavering. The severity lens argues a finding is overgraded, and the only step below the floor is "it does not exist" — which is the existence lenses' question, and they were not asked this one. - Contested (exactly one existence lens refuted) — stands, at whatever grade the severity tally left it, and is marked as contested in the render.
A candidate can be contested and downgraded at once. Apply each verdict on its own terms; neither substitutes for the other.
- Dropped (both existence lenses refuted) — loses its
Render delta
The brief keeps its shape. Two differences:
- A one-line tail sits under the blockquote header naming the pass: "Verify mode — N findings weighed by panel, M refuted, K downgraded, J standing contested." It is appended after Deep mode's tail line when both flags are active. Because the tallies are independent, the last two counts may name the same finding twice; they are properties, not buckets. Both earn their place — a downgraded finding and a contested one are each a different thing from an unopposed one, and the reader deserves to know which counsel the panel itself could not agree on before acting on it.
- A
## To passrow that survived on a split existence vote carries a trailing(contested)after its text — one existence lens argued it away and the other did not. The mark says nothing about the row's grade: a contested row that the severity lens also refuted renders at the lower grade and keeps the mark.
A verify run costs three opus calls per candidate finding — on a wavering branch bearing eight, twenty-four agents on top of the weighing itself. Reach for it where a wrong finding would cost something real: a verdict about to be posted publicly, a branch whose correctness is subtle, a mend loop that will act on the To pass list unattended. Not for a routine read.
Fix mode (--fix) — mend the Nits
The verdict is counsel; --fix lets the Pilgrim lend a hand with the smallest of it. After the brief renders, the ### Nit rows are fixed in the working tree. It is a flag, not a verb, and it rides the branch-path only: a local checkout is the one place an edit has somewhere to land. Blocker and Nice-to-have rows are never touched — they carry a judgment the author owes, and they stay in ## To pass as rendered.
Mechanism
- Weigh first, with the panel. Run the branch-path as usual with Verify mode forced on. The rows to mend are the
### Nitrows of the reconciled list — a row the panel dropped is gone, and a Nice-to-have the severity lens lowered to Nit counts as a Nit. - Skip what is not safe to mend. Leave a row untouched, and name it with its reason in the report, when it is:
(contested)— one existence lens argued it away, so the flaw is not settled enough to act on;- without a
file:line, or needing a decision only the author can make; - in a file bearing uncommitted changes, or outside the files the diff changed.
- Fix the rest. Read the cited
file:linefirst, then make the smallest edit that answers the row. Touch nothing the row does not name. - Check once. Run the project's static analysis (whatever
/argonath's detection names, or the repo's own lint command) once after the edits. On a failure, mend it and re-run once; if it still fails, restore the edited files withgit checkout -- <files>— safe, since step 2 admitted only clean files — and report the failure plainly. With no such command, say so. - Stop at the tree. No
git add, no commit, no push — the fixes stand as an uncommitted diff for the author to read, keep, or discard.
Render delta
The brief is unchanged: it shows the verdict as weighed, before the mend. One line follows the closing paragraph: "Fix mode — fixed K of N Nit rows; left: <row — reason>, …". Verify mode's tail line, where present, stands above it.
The fix pass is the skill body's own work. Deep-mode and verify-mode subagents stay read-only.
URL dispatch
Match the URL against two host-agnostic patterns (case-insensitive):
| Forge | Pattern | Read hook | Comment hook |
|---|---|---|---|
| GitHub | https?://[^/]+/(?<owner>[^/]+)/(?<repo>[^/]+)/pull/(?<n>\d+) |
~/.claude/hooks/lindir-github-pr.sh |
~/.claude/hooks/mithrandir-github-comment.sh |
| GitLab | https?://[^/]+/(?<group>.+)/-/merge_requests/(?<iid>\d+) |
~/.claude/hooks/lindir-gitlab-mr.sh |
~/.claude/hooks/mithrandir-gitlab-comment.sh |
The host portion is not fixed to github.com or gitlab.com. Self-hosted forges work the same — gh and glab resolve the host from the URL or their own config.
If neither pattern matches, stop with: "Mithrandir does not know that URL — it bears no PR or MR mark."
Mithrandir reuses Lindir's read hooks unchanged; only the comment hooks are new.
Workflow — branch-path (no argument or branch verb)
With --repo <path>, every git command this path runs — the steps below and the git remote get-url origin the style axes read — runs as git -C <path> …, the call-graph search (Regression risk) runs over <path>, and deep-mode reviewers read files from <path>. Without it, all three use the current working directory.
1. Resolve the base
Find the project's default base branch — the first of master, main, origin/HEAD that resolves locally:
git rev-parse --verify --quiet master \
|| git rev-parse --verify --quiet main \
|| git rev-parse --verify --quiet origin/HEAD
If none resolves, stop with: "Mithrandir cannot find a base — neither master, main, nor origin/HEAD stands in this repo."
2. Resolve the current branch
git rev-parse --abbrev-ref HEAD
If the current branch matches the resolved base, or is one of the literal names master or main, stop with: "Nothing to review — you stand on the default branch. Branch off first, then summon Mithrandir." No diff is captured, no agents weighed.
3. Capture the diff
git diff $(git merge-base HEAD <base>)..HEAD
If the diff is empty, stop with: "Nothing to review — the branch stands even with its base."
4. Build synthetic metadata
The axis weighing wants a metadata block. Compose it from local git:
| Field | Source |
|---|---|
title |
git log -1 --format=%s of the branch tip; if vague (e.g. wip), fall back to the branch name |
author |
git log -1 --format=%an |
state |
the literal string local |
head |
the current branch name |
base |
the resolved base name |
description |
concatenated commit subjects from git log <base>..HEAD --format=%s, one per line; if a single commit, its body |
files |
derived from the diff (path + per-file additions/deletions, same shape as the URL hooks emit) |
5. Weigh and render
Run steps 4 and 5 of the read-path verbatim — the axis weighing and the brief render. The branch-path uses the same render shape; only the diff source differs.
The comment verb is not available on the branch-path. There is no PR to comment on — the verdict renders to chat alone. If the user wants a public verdict, they must open a PR/MR first and run /mithrandir comment <url> against it.
6. Fix the Nits (only with --fix)
With --fix, run Fix mode above on the rendered To pass list, then append its one-line report. Without the flag, the path ends at step 5.
Workflow — read-path (URL only)
1. Forge dispatch
Match the URL. Resolve the read hook from the table above.
2. Invoke the hook for metadata
<read-hook> <url>
The hook prints a single JSON object:
{
"title": "...",
"number": 42,
"author": "...",
"state": "...",
"head": "feature-x",
"base": "master",
"description": "...",
"files": [
{ "path": "src/foo.rs", "additions": 12, "deletions": 3 }
]
}
If the hook prints {"error":"..."}, surface the error and stop.
3. Invoke the hook for the diff
<read-hook> --diff <url>
The hook prints the raw unified diff to stdout. Read it; this is the ground for the verdict.
If the diff call fails, render the verdict on metadata alone (description and file list) and append a one-line tail-note: "(diff unavailable — verdict rendered on metadata only)".
4. Weigh the axes
Thirteen axes are defined. Six are always rendered (Stability, Performance, Coding style, Maintainability, Correctness, Regression risk); the other seven are conditional — they render only when the diff touches their domain, and are silently omitted from the brief otherwise (no n/a row).
If --deep is active, do not weigh the whole diff in one pass — fan out one focused reviewer (general-purpose, model: opus — passed explicitly on every Agent call) per changed file per Deep mode above, then aggregate the per-file findings into the axis verdicts and the ## To pass section. The axis definitions, conditional-fire rules, and render shape are otherwise unchanged.
If --verify is active, once the axes are weighed — by the holistic pass described in this same step, or by the deep-mode aggregate — run the refutation panel of Verify mode above before proceeding to step 5: three general-purpose, model: opus subagents per candidate finding, one per lens — both existence lenses must refute to drop it, while the severity lens only lowers its grade. Render on the reconciled finding set, never the pre-panel one.
First, read close — the per-symbol pass
The verdict is only as sharp as the read beneath it. Before weighing any axis, walk the diff hunk by hunk and name every symbol the change adds or alters — each method, function, widget, getter, class. Hold each against the high-signal patterns a holistic skim glides past, the ones a line-level reading catches:
- Unhandled async — an
awaiton a fallible call (network, disk, provider) with notry/catcharound it; anasyncfunction fired from a synchronous callback (onTap,onPressed, a listener) withoutawaitand without its own internal guard, so the rejected Future escapes uncaught and the user sees nothing; aFuturereturned and dropped; a.then()with noonError. - Swallowed or misrouted failure — a
catchthat discards the error, a fallback that hides a real fault, an error logged but never surfaced where the caller can act. - The comment the diff itself outgrew — a
TODO/FIXME/ explanatory comment that this very change has already resolved or contradicted: wiring the feature theTODOcalls deferred, describing a shape the code no longer holds. Cross-check each comment against the post-change code beneath it. - The unguarded edge — an index into a possibly-empty container, a null where the type allows it, an unconsidered timeout, a race between two callers.
This pass is internal — it feeds the axis verdicts below; it is not itself rendered. Its purpose is to force the close read: a flaw named here lands in Stability, Correctness, or Maintainability; a flaw never looked for lands nowhere, and the verdict reads "sound" over a fault the diff plainly carried.
Then weigh
Form a per-axis verdict on each rendered axis, grounded in what the close pass surfaced and what the diff and metadata show. Each axis carries its own tier (sound | wavering | off) and a single short evidence clause. Cite a file or path when naming a concrete flaw.
Always-on axes
Stability — does the code hold up under stress? Edge cases, error paths, concurrency, the failure modes that do not show on a happy-path test. Probe: what breaks when input is empty, when the network drops, when two callers race? An unguarded null, a swallowed exception, an unconsidered timeout, an unawaited Future, an async call fired from a sync callback with no guard each cost stability.
Performance — runtime cost, memory, allocations. Avoidable expense in hot paths — N+1 queries, blocking calls inside a loop, allocations on every render, recompute where memoise would serve. Probe: does this slow the path the user pays for, or hold memory longer than it must?
Coding style — alignment with the codebase's idioms: naming, formatting, structural conventions, language patterns. Probe: does the change read in the same voice as the surrounding files? When the diff lives in one of your own GitHub repos — for URL-paths, the URL matches
https?://github\.com/LarryHsiao/...(case-insensitive); for branch-path,git remote get-url originmatchesgit@github\.com:LarryHsiao/...or the equivalent HTTPS form — also weigh it against the personal style rules in~/.claude/docs/style/general.md, (for Dart files)~/.claude/docs/style/flutter.md, and (for React/JS/TS files)~/.claude/docs/style/react.md. For any other owner, any other forge, or a self-hosted GitLab, judge by the repo's own grain alone — personal rules do not travel onto teammate or third-party work, and acommentpost would otherwise carry your house style into public counsel. Independently of that gate, for Dart files (.dart) in any repo — your own or a teammate's, on any forge — also weigh the change against the official Effective Dart conventions in~/.claude/docs/style/dart-official.md. These are the language's own recommended rules, not personal house style, so they travel onto every Dart review and are safe to cite in publiccommentcounsel.Maintainability — cost to the next reader. Modularity, cognitive load, readable naming, sensible decomposition, the absence of clever-but-opaque tricks, and the absence of dead weight — code that no longer earns its place on the page. Probe: would a stranger six months from now know what this does and why, and would they have to wade through anything that no longer earns its place? Concrete shapes that cost the axis include dead code (unused imports, unreachable branches, always-false guards, large commented-out blocks, unreferenced private symbols), control-flow nesting past three indents, magic literals, positional boolean-flag call sites, mixed abstraction levels within one method, and stale or contradictory comments (including a TODO this very change has already completed). When the diff lives in one of your own GitHub repos — for URL-paths, the URL matches
https?://github\.com/LarryHsiao/...(case-insensitive); for branch-path,git remote get-url originmatchesgit@github\.com:LarryHsiao/...or the equivalent HTTPS form — also weigh it against the## Maintainabilityrules in~/.claude/docs/style/general.md. For any other owner, any other forge, or a self-hosted GitLab, judge by the repo's own grain alone — personal rules do not travel onto teammate or third-party work, and acommentpost would otherwise carry your house style into public counsel. Independently of that gate, also weigh it against~/.claude/docs/style/universal.md— these rules travel onto every repo, yours or a teammate's, on any forge, the same way Effective Dart does for Coding style, since they hold regardless of whose codebase it is. New rules added there apply automatically; this clause needs no edit when the file grows.Correctness — does the code do what it claims? Off-by-ones, wrong API use, mismatched control flow, conditions that read "if" but mean "unless". Probe: does the function's behavior match its name and its callers' expectations? Distinct from Stability — Stability asks whether the code crashes; Correctness asks whether the code is right.
Regression risk — breakage outside the diff's own hunks. Every function, method, or exported symbol the change alters has callers, and the new behaviour must still serve each of them. Probe: who else depends on what just changed, and does the diff show they were considered? Shapes that cost the axis: an altered return value or thrown error no caller handles, a parameter contract narrowed or widened under existing call sites, a default changed beneath callers who relied on the old one, a renamed or removed symbol with a call site left behind, a persisted-shape or schema change with no migration beside it, and a behaviour change inside a shared helper whose other callers the diff never touches. Distinct from Backward compatibility — that axis fires only on a public surface and asks whether external consumers still work; this one is always on and asks whether this repo's own callers still work, including entirely internal ones.
How far the search reaches depends on the path, and the verdict must say so. On branch-path the working tree is in hand: name every altered symbol, then search the tree for each one (
rg,grep, or the project's own tooling) before forming the verdict. On URL-path only the raw diff is in hand — the one checkout worth trying is the current working directory, taken whengit remote get-url originnames the sameowner/repoas the URL; search it then, and do not hunt the filesystem beyond it. Failing that, judge from the diff alone and end the evidence clause with "call sites not visible". An unsearched call graph must never be rendered as an empty one; asoundtier earned by not looking is the failure this axis exists to prevent.
Conditional axes — fire when the diff touches the domain
- Test coverage — does the change carry tests for new behavior, new branches, new error paths? A new function without a test, a new branch without a test, a new boundary without a test each cost coverage. Probe: if this change broke tomorrow, would a test catch it before a user did? Fires when the codebase has any test infrastructure (
test/,spec/,__tests__/,*_test.go,androidTest/, an Xcode*Teststarget, etc.). Skips on prototypes and one-off scripts where no tests exist anywhere in the tree. - Security — fires when the diff matches any cluster below; a diff can match more than one. Probe common to all: does the change widen any surface — network, DOM, cross-window, device storage, IPC — that a hostile actor could reach? Skips on pure refactors that touch no cluster's surface.
- Web/backend: injection (SQL, command, template), secrets in code or logs, auth bypass, broken access control (IDOR — a handler that trusts a client-supplied ID without checking the caller owns or may access that resource; forced browsing — a route reachable by knowing its exact URL/path with no permission gate in front of it; missing function-level authorization — an endpoint that authenticates the caller but never checks whether that caller may perform this specific action), CSRF (a state-changing endpoint — POST/PUT/PATCH/DELETE — with no anti-CSRF token and no
SameSitecookie protection), mass assignment (a request body bound directly to a model/entity, letting the caller set fields — role, isAdmin, price — never meant to be client-writable), security misconfiguration (permissive CORS such as a wildcard origin paired with credentials, verbose stack traces or debug output returned to the client, missing security headers — CSP, HSTS, X-Frame-Options), SSRF (an outbound request — webhook fetch, image proxy, URL preview — whose target is attacker-influenced and could reach an internal-only service), session management flaws (missing Secure/HttpOnly/SameSite cookie flags, a session left valid after logout or password change, session fixation), insecure file upload (unrestricted file type or size, or a filename used unsanitised in a path — path traversal via upload), missing input validation, deserialisation hazards, broken cryptography. Fires when the diff touches user input handling, network code (inbound or outbound), auth/session, file IO, file upload handling, command execution, crypto, templating, CORS or security-header configuration, or adds/modifies a route, endpoint, controller, or resource-access handler. - Browser/frontend: cross-site scripting / XSS (reflected, stored, or DOM-based — unsanitised input written into the DOM via
innerHTML/dangerouslySetInnerHTML, unescaped template interpolation, oreval/Functionon untrusted input), insecure client-side token storage (a session token or JWT kept inlocalStorage/sessionStorageinstead of anhttpOnlycookie), insecurepostMessagehandling (awindow.postMessagelistener that trusts the message without checkingevent.origin). Fires when the diff touches DOM rendering, client-side storage of tokens or credentials, or cross-window messaging. - Mobile (Flutter/iOS/Android): insecure local storage (a token or credential written to plaintext
SharedPreferences/UserDefaults/a local database instead of Keychain- or Keystore-backed encrypted storage), exported Android components (anActivity/Service/BroadcastReceiver/ContentProvidermarked, or implicitly made,exportedwith no signature-level permission), insecure deep link / URL scheme handling (a deep link or universal link that triggers a sensitive action without verifying the caller or validating the payload), WebView misconfiguration (a JS-to-native bridge such asaddJavascriptInterfaceexposed to untrusted content, or JavaScript enabled on a WebView loading non-trusted URLs), disabled or bypassed TLS/certificate validation (pinning removed, a trust-allTrustManager/URLSessiondelegate), secrets bundled in the app binary (an API key compiled into the APK/IPA, extractable by reverse-engineering the bundle rather than reading the source repo). Fires when the diff touches a platform-local storage API, an Android manifest or intent filter, a deep-link/URL-scheme handler, WebView configuration, or TLS/certificate-pinning setup.
- Web/backend: injection (SQL, command, template), secrets in code or logs, auth bypass, broken access control (IDOR — a handler that trusts a client-supplied ID without checking the caller owns or may access that resource; forced browsing — a route reachable by knowing its exact URL/path with no permission gate in front of it; missing function-level authorization — an endpoint that authenticates the caller but never checks whether that caller may perform this specific action), CSRF (a state-changing endpoint — POST/PUT/PATCH/DELETE — with no anti-CSRF token and no
- Documentation — public API docs (docstrings, dartdoc, javadoc, JSDoc/TSDoc, DocC), comments where the WHY is non-obvious, README / CHANGELOG sync for public-facing change. Probe: would a caller reading the symbol know how to use it without reading the implementation? Fires when the diff adds or alters public symbols, exported APIs, or user-visible README / CHANGELOG content.
- Backward compatibility — public-surface changes, data migration safety, consumer breakage, config-schema renames. Probe: does an existing caller, persisted record, or deployed config still work after this change? Fires when the diff touches a public API, an exported type, a database schema, a wire protocol, or a stable config key. Skips on internal-only changes. Distinct from Regression risk — that axis is always on and covers this repo's own callers, internal ones included; this one fires only on a published surface and asks after consumers you do not control.
- Observability — logs, metrics, traces, and errors surfaced where someone can see them. On the server this means structured logs, metrics, and distributed traces; on frontend or mobile it means crash reporting wired to a critical flow, breadcrumbs or structured logging ahead of a likely failure point, and error boundaries that surface a failure rather than swallow it silently. Probe: when this fails in production at 3am — or crashes on a user's device — is there enough signal to diagnose without a debugger? Fires when the diff adds or modifies a long-running process, a request handler, a background job, a scheduled task, a critical-path server operation, or a critical user-facing flow (auth, payment, onboarding, checkout, an action risking data loss) on frontend or mobile — including any crash-reporter, breadcrumb, or analytics wiring around such a flow. Skips on cosmetic UI changes with no failure risk (styling, layout, copy) and on pure-library work with no runtime signal to emit.
- Dependency hygiene — new packages weighed for size, license, maintenance health, transitive bloat; pinned versions where unpinned would be unsafe. Probe: does this dependency carry weight proportional to the value it brings? Fires when the diff touches
package.json,pubspec.yaml,Cargo.toml,go.mod,requirements.txt,Gemfile,Podfile,Package.swift,build.gradle/build.gradle.kts, or any other dependency manifest. - Accessibility & i18n — keyboard navigation, screen reader labels (
aria-label, FlutterSemantics, iOSaccessibilityLabel, AndroidcontentDescription), focus management, colour contrast, minimum touch-target size (a11y); locale handling, plurals, date / number formatting, RTL support (i18n). Probe: does the change degrade for a user on a different language, on a screen reader (VoiceOver, TalkBack, or a desktop equivalent), or on a keyboard-only path? Fires when the diff touches user-facing UI: rendered widgets, screens, dialogs, user-visible strings.
5. Render the brief
Render in this order, in plain markdown. The title line and closing paragraph follow the active tone mode (see Tone modes above):
> <gauge> **<action>** — <one-clause justification, ≤ 15 words>
# <title-line>
<head> → <base> · +<adds> -<dels> across <k> files
**Stability** <gauge> <tier> — <evidence clause, ≤ 25 words>
**Performance** <gauge> <tier> — <evidence clause, ≤ 25 words>
**Coding style** <gauge> <tier> — <evidence clause, ≤ 25 words>
**Maintainability** <gauge> <tier> — <evidence clause, ≤ 25 words>
**Correctness** <gauge> <tier> — <evidence clause, ≤ 25 words>
**Regression risk** <gauge> <tier> — <evidence clause, ≤ 25 words> ← append "call sites not visible" when the call graph could not be searched
**Test coverage** <gauge> <tier> — <evidence clause, ≤ 25 words> ← conditional; render only if the codebase has test infrastructure
**Security** <gauge> <tier> — <evidence clause, ≤ 25 words> ← conditional; render one line only if the diff matches any cluster's trigger (web/backend, browser/frontend, or mobile — see axis definition); draw tier and evidence from whichever cluster(s) fired
**Documentation** <gauge> <tier> — <evidence clause, ≤ 25 words> ← conditional; render only if the diff alters public symbols or user-visible docs
**Backward compatibility** <gauge> <tier> — <evidence clause, ≤ 25 words> ← conditional; render only if the diff touches public surface, schemas, or stable config
**Observability** <gauge> <tier> — <evidence clause, ≤ 25 words> ← conditional; render only if the diff touches request paths, jobs, critical-path code, or a critical user-facing flow on frontend/mobile
**Dependency hygiene** <gauge> <tier> — <evidence clause, ≤ 25 words> ← conditional; render only if the diff touches a dependency manifest
**Accessibility & i18n** <gauge> <tier> — <evidence clause, ≤ 25 words> ← conditional; render only if the diff touches user-facing UI
## Worth keeping ← optional; render only if there are concrete bright spots
- `<file or topic>` — <praise grounded in a specific decision, ≤ 50 words>
## To pass ← optional; render only if at least one row exists across the three groups
### Blocker ← omit subsection if empty
- `<file or path>` — <action ≤ 50 words>
### Nice to have ← omit subsection if empty
- `<file or path>` — <action ≤ 50 words>
### Nit ← omit subsection if empty
- `<file or path>` — <action ≤ 50 words>
## Verdict <gauge> <overall-tier>
<one short paragraph in the active tone>
The gauge mirrors the task-sizing gauge from CLAUDE.md:
▰▱▱ sound— ready as it stands; no concern of weight.▰▰▱ wavering— landable, but flaws to address; name them in the closing paragraph.▰▰▰ off— should not land in this shape; the closing paragraph names what must change.
Aggregation. The overall verdict is the highest concern among the rendered axes (always-on plus any conditional that fired):
- Any rendered axis
off→ overalloff. - Else any rendered axis
wavering→ overallwavering. - Else (every rendered axis
sound) → overallsound.
Unfired conditional axes do not weigh into aggregation — they are silent, not implicitly sound.
Header line. The first line of the brief is a blockquote that distils the bottom line — should this merge? — to a single clause. Action label by tier:
sound→ Merge (ready to land)wavering→ Hold (concerns to mend first)off→ Refuse (do not land in this shape)
The justification clause is ≤ 15 words and aligns with the chief concern named in the closing paragraph. The same labels apply in both lore and plain modes.
Evidence clause. Each axis's evidence line is bounded at twenty-five words. Word count is the unit; in Chinese rendering, character count ≤ 50 is the equivalent. The clause is grounded — names a file, count, or fact, not a feeling.
Closing paragraph. Three sentences at most. Names the chief concern, or plainly affirms the work when the verdict is sound. In lore mode the paragraph may carry council / Imladris / Elrond diction; in plain mode it stays in neutral reviewer voice with no persona, no similes, no lore-words.
Worth keeping section. Optional. Render only when there are concrete bright spots — specific decisions, named files or patterns the work has done well. Heading is ## Worth keeping in both modes. Each bullet ≤ 50 words; lead with a backticked file path or topic. Praise must be grounded in a specific decision the diff makes; do not pad with generic affirmations. If there is nothing genuine to keep, omit the section entirely — the praise must mean something or it loses weight.
To pass section. Optional. The action list — concrete things the author should do for the work to merge — grouped by severity rather than by axis. Heading is ## To pass in both modes. Three subsections, each rendered only if it has at least one row:
### Blocker— must mend before merge. The work cannot land in good conscience until each is addressed.### Nice to have— worth doing; does not block merge. The work can land without these, but they round its edges.### Nit— minor, stylistic, or follow-up bookkeeping. Optional even by suggestion.
Each row is a bullet, ≤ 50 words, leading with a backticked file path (with line span where useful). Phrase as an action — "assign … before await", "name the iM3 hardware verification" — not as a passive observation. If all three subsections are empty, omit ## To pass entirely; the verdict alone carries the message.
Workflow — write-path (comment)
1. Forge dispatch
Match the URL. Resolve both the read hook (for metadata + diff) and the comment hook from the table above.
2. Render the verdict
Run the read-path workflow steps 2–5 in full. The rendered brief is what will be posted; what you see in chat is exactly what the forge will see (modulo the confirm prompt that follows).
Verify mode is on by default here. Everywhere else --verify is opt-in, because a wrong finding in chat costs a moment's reading. This verb writes on someone else's pull request, where a wrong finding costs their time, their trust in the next verdict, and — since /narvi reads a posted To pass list and commits against it — possibly a real edit to answer a flaw that was never there. That is the case the panel was built for, so the default flips. Pass --no-verify to post without it, when the diff is small enough to read whole and the cost is not worth paying.
The default tone for the comment verb is plain; the default flips to lore only if --lore is passed.
3. Confirm-once gate
Always ask via AskUserQuestion before invoking the comment hook. The slash invocation alone is not authority for a forge write.
Build the prompt as:
Post this verdict as a comment on <url>?
<title>
<head> → <base>
Tier: <tier>
with options Yes, post and No, cancel. On No, stop with "Counsel withheld.". On Yes, proceed.
4. Invoke the comment hook
<rendered-verdict> | <comment-hook> <url>
The hook reads the body from stdin and posts it as a comment via gh pr comment (GitHub) or glab mr note (GitLab).
On success the hook prints one line:
commented: forge=<github|gitlab> url=<url> number=<n>
On failure the hook surfaces the forge's error verbatim and exits non-zero. Surface that error and stop. Do not retry.
Common failures the hook does not paper over:
- 403 / scope missing — the token cannot post comments.
- MR / PR closed or locked.
- Network / auth errors from
ghorglab.
These are the forge's word; Mithrandir relays without translation.
5. Report
One short block:
- The URL.
- The forge.
- The token (
commented). - The PR/MR number from the success line.
A comment left standing is picked up later by /moria, which sweeps repos for unaddressed comments and answers them with code — the first link of the mend cycle. comment itself never calls it.
Workflow — bless-path (bless / recheck)
bless is the verb of return. After comment has named flaws and the author has amended, bless re-weighs the work as it now stands and threads a reply onto the original verdict — affirming if the work has earned it, partial if some flaws still stand, withheld (chat-only) if not.
The verb has one alias: recheck. They are the same path. bless reads of a wizard giving his word; recheck reads neutral about outcome. The user picks the tongue; Mithrandir answers either summons the same way.
1. Forge dispatch
Match the URL. Resolve three hooks:
| Forge | Read hook | Prior-finder | Post hook |
|---|---|---|---|
| GitHub | ~/.claude/hooks/lindir-github-pr.sh |
~/.claude/hooks/mithrandir-github-prior.sh |
~/.claude/hooks/mithrandir-github-comment.sh |
| GitLab | ~/.claude/hooks/lindir-gitlab-mr.sh |
~/.claude/hooks/mithrandir-gitlab-prior.sh |
~/.claude/hooks/mithrandir-gitlab-reply.sh |
GitHub PR-level comments are issue comments — the forge bears no native thread reply for them. The bless body therefore opens with a back-link to the prior verdict; that link is the thread on GitHub.
GitLab notes belong to a discussion — the reply hook posts into the prior verdict's discussion via glab api, so the bless lands as a true thread reply. The back-link is rendered in both bodies for symmetry.
2. Find the prior counsel
<prior-finder> <url>
The hook prints either a single URL on stdout (GitHub) or a JSON line {discussion_id, note_id, note_url} (GitLab) — the most recent comment-verb post by Mithrandir on the PR/MR. Match is strict: only original verdicts (Merge / Hold / Refuse blockquote headers) qualify; bless posts (All resolve / Partial okay) are not their own anchors. So every bless on a given PR/MR threads back to the same original counsel, no matter how many rechecks have come between.
If stdout is empty, stop with:
No prior counsel found on
<url>. Mithrandir blesses what he has spoken; summoncommentfirst, then return for the blessing.
No further hooks run, no forge write.
3. Re-weigh
Run the read-path workflow steps 2–4 verbatim — fetch metadata, fetch diff, weigh the axes. The diff is the new diff; the modified file list is the new file list. The prior verdict's body is not read or compared; the work is judged as it now stands. The signature match in step 2 only fixes the anchor — the verdict is fresh.
Verify mode is on by default here too, and for the same reason as the comment verb: this path writes to the forge. It matters more, if anything — bless is read after someone has already amended their work in answer to a prior verdict, and a false finding at that moment tells them the mending failed when it did not. Pass --no-verify to skip the panel.
4. Branch on the overall tier
The bless-path renders one of three outcomes:
| Tier | Outcome | Posts? | Header |
|---|---|---|---|
sound |
All resolve | yes | > ▰▱▱ **All resolve** — every flaw once named is mended; okay to merge. |
wavering |
Partial okay | yes | > ▰▰▱ **Partial okay** — the chief flaws are mended; <k> open item(s) remain. |
off |
Withhold | no | (no post; chat-only render of the fresh verdict, with a tail line) |
For sound and wavering, the body shape mirrors the read-path render with two substitutions:
- The blockquote header label is All resolve or Partial okay, not Merge / Hold / Refuse.
- The
## To passheading becomes## Still open. Structure is identical (Blocker / Nice to have / Nit subsections, each rendered only if non-empty); only the heading changes — a partial bless reports what remains, it does not re-issue a fresh verdict.
The axis lines, optional ## Worth keeping, file count, and closing paragraph all remain.
The body opens with a back-link, before the title line:
In response to my prior counsel: <prior-url>
This is rendered in both forges' bodies for symmetry. On GitLab, the threading already binds the reply; on GitHub, the back-link is the only signal of relation.
For off, no comment is posted. Render the fresh verdict to chat (read-path render shape, including the original Merge / Hold / Refuse blockquote header) and append:
Counsel withheld — the work is not yet sound. Mend further, then summon the blessing again.
5. Confirm-once gate
For sound and wavering only — off posts nothing and skips this step.
Build the prompt as:
Post this <full|partial> blessing as a <comment|reply> on <url>?
<title>
<head> → <base>
Tier: <tier>
Use comment in the wording for GitHub, reply for GitLab — the difference reflects the actual surface. Options Yes, post and No, cancel. On No, stop with "Blessing withheld.". On Yes, proceed.
6. Invoke the post hook
GitHub:
<rendered-body> | <post-hook> <url>
GitLab:
<rendered-body> | <post-hook> <url> <discussion-id>
On success the hook prints one line:
- GitHub:
commented: forge=github url=<url> number=<n> - GitLab:
replied: forge=gitlab url=<url> number=<iid> discussion=<id>
On failure the hook surfaces the forge's error verbatim and exits non-zero. Surface that error and stop. Do not retry.
7. Report
One short block:
- The URL.
- The forge.
- The token (
commentedfor GitHub,repliedfor GitLab). - The PR/MR number from the success line.
- The discussion id (GitLab only).
- The back-link to the prior counsel that anchored this bless.
Rules
- Read-path is silent on writes — it only fetches and renders.
--fixis the one local write: branch-path only,Nitrows only, uncommitted, and never on a(contested)row or a file already bearing uncommitted changes. It implies--verify, and it refuses--no-verifyand every URL path.- Write-path and bless-path always ask once. No opt-out flag in v1; the gate is unconditional.
- Branch-path is local-only: no URL, no
comment, nobless, no forge write. If the user wants public counsel on a branch, they must open a PR/MR first and runcomment(or laterbless) against the URL. - Branch-path refuses to ride on the default branch (
master/main/ the resolved base). When the current branch is the base, there is nothing to review — stop and say so. - One verdict per axis; one tier overall; one paragraph of reasoning. No bullet swarms.
- Grounded in the diff — when a flaw is named, name the file (and line where it serves).
- The bless-path writes only when prior counsel exists. If no
comment-verb post is found on the PR/MR, the bless-path stops; nothing is posted. - The bless-path is read-path-grounded — it re-weighs fresh and never compares against the prior verdict's text. The signature match locates the anchor; the body is not parsed.
- A bless
offre-weigh posts nothing — the author sees the fresh verdict in chat, mends further, and summons again. blessdoes not approve on the forge in the Lindir sense — it posts a comment (or thread reply on GitLab), not a review-approval. If a forge approval is also wanted, run/lindir approve <url>separately.recheckis an alias forbless; they take the same path, the same hooks, the same gate.- If the URL matches no forge: "Mithrandir does not know that URL — it bears no PR or MR mark."
- Counsel is offered; the decision rests with Elrond. Mithrandir does not insist.
- Tone defaults follow the audience: lore for chat, plain for forge. The flags override either default.
--plainand--loreare mutually exclusive; passing both stops the run.--deepcomposes with any weighing path (branch / read / comment / bless) and with either tone flag; it is not a write path of its own. When it leaves no hand-written file to weigh, fall back to the holistic pass and say so.- Deep-mode subagents are read-only — they find and report; they do not edit, commit, or post. The skill body owns aggregation and any forge write, exactly as in the holistic pass.
--verifycomposes with any weighing path (branch / read / comment / bless), with either tone flag, and with--deep; it is not a write path of its own. It runs after axis weighing and before rendering, on whichever finding set the weighing produced — holistic or deep.- Verify mode's default follows the audience, as tone does: off on the branch and read paths (chat), on for the
commentandblessverbs (the forge).--no-verifyis the opt-out where it is on,--verifythe opt-in where it is off; either naming the standing default is a harmless no-op, and passing both stops the run. A verdict written on someone else's work carries a cost a chat verdict does not, and that asymmetry is the whole reason the defaults differ. - Verify-mode panels are read-only and three-strong, one reviewer per lens (mechanism / context / severity), each carrying
model: opusexplicitly. They argue only; they do not edit, commit, or post. A finding falls only when both existence lenses refute it; the severity lens lowers a grade and never drops anything. Never a lone refuter, and never a panel of identical reviewers — correlated errors make their votes worth no more than one. The skill body owns reconciliation and any forge write, exactly as in the holistic and deep passes. - Do not surface forge tokens in logs, responses, or saved files.
- Host-agnostic — the URL chooses the forge; the host portion is not pinned.
- Do not edit
~/.claude/,~/.bashrc, or anything outside the repo root.