Advisor Mode
Session posture for expensive orchestrator models (Fable/Opus): /advisor puts the main agent into a delegation-only mode - it writes specs, dispatches cheaper implementer agents, personally reviews spec compliance and quality by default (cross-provider review is opt-in), triages findings, and merges, while a hard PreToolUse gate (exit 2, bypass-surviving) blocks its own file edits and non-orchestration Bash. Subagent calls pass untouched; a per-turn posture injection keeps the model delegating instead of fighting denials. ADVISOR_DIRECT=1 one-off hatch. State is per session: every session starts in the mode, and CCGM_ADVISOR_AUTO=false opts out.
Tags
README
Advisor Mode
Session posture for expensive orchestrator models (Fable/Opus): the main agent specs, delegates, reviews, and merges — it never implements. Enforcement is mechanical: while the mode is on, a PreToolUse hook hard-blocks the main agent's file edits and non-orchestration Bash (exit 2, which survives bypass mode). Subagent tool calls pass untouched, so delegated implementers work normally.
/etp already runs the delegate → personal lead review → fix → follow-ups loop for
ready work (plans, investigated issues). Advisor mode is the standing posture
that routes ad-hoc work through the same loop instead of letting the
expensive model implement inline — and hands etp-shaped work to /etp.
Why hard enforcement
Prompt-only "you never implement" orchestrators demonstrably drift into
hands-on patching under integration pressure; every documented production fix
for that drift was capability removal, not better prompting. Full prior-art
research: ~/code/docs/research/claude-code-advisor-delegation-mode/research.md.
The guard's main/subagent discriminator (hook-input agent_id/agent_type,
absent on main-agent calls) and the per-turn posture injection are adapted from
the baton plugin's design. Discriminator drift is asymmetric: main-agent
inputs gaining the fields makes the guard inert (fails open, visibly); subagent
inputs losing them would deny subagents too — loud and recoverable with
/advisor off, never a silent misroute.
State is per session
The flag is ~/.claude/advisor-mode/<session_id>, keyed by the session_id
every hook input carries (CLAUDE_CODE_SESSION_ID is the fallback). One
session's mode never binds another's — with many sessions running at once, a
single machine-global file meant one session's /advisor off removed the gate
from all of them.
Every session starts in advisor mode. Opt out with CCGM_ADVISOR_AUTO=false in
the environment or in ~/.claude/.ccgm.env. Only compaction is exempt from the
auto-on, so /advisor off survives a compaction but a resume or a /clear
starts the mode again. The flag is removed when the session ends, and flags
left by sessions that died are swept at the next session start.
Files
| File | Role |
|---|---|
commands/advisor.md |
/advisor toggles this session; explicit on|off|status accepted — status also lists every session in the mode |
rules/advisor-mode.md |
The posture contract: loop, delegation ladder, floor, review contract |
hooks/advisor-guard.py |
PreToolUse exit-2 gate: file writes confined to work-product paths; Bash default-deny with read-only + orchestration allowlist |
hooks/advisor-posture.py |
UserPromptSubmit injection while this session's flag exists; also names the session id |
hooks/advisor-session-start.py |
SessionStart: creates this session's flag, migrates the legacy global file, sweeps dead sessions' flags |
hooks/advisor-session-end.py |
SessionEnd: removes this session's flag |
settings.partial.json |
Hook registration (merged into settings.json) |
What the main agent keeps
Reading, conversation, spec-writing, agent dispatch, findings triage, and the
orchestration verbs: read-only git, git checkout/switch/pull/fetch/worktree,
gh PR/issue/run/label management including gh pr merge, and file writes
under ~/.claude/, temp/scratchpad roots, ~/code/plans/, ~/code/docs/,
worktree checkouts, and plan-mode plan files.
Read-only recon passes too: dev-tool version and identity probes (node -v,
wrangler whoami, not pnpm install), shell grouping tokens, and
$(...)/backtick substitution whose inner commands are themselves
allowlisted (checked recursively, depth-capped, backtick bodies
unescaped first). What a checked substitution returns is an argument for
read-only commands only: echo $(git rev-parse HEAD) passes,
sed $(echo -i) … does not.
Every argument is read the way bash will pass it — quotes removed
('-i', -''i, $'-i', $"-i"), adjacent spans joined, backslash escapes
and line continuations dropped — so spelling a flag differently no longer
hides it. What bash does that the guard cannot reproduce is refused instead
of guessed at, because brace expansion, $IFS word splitting and globbing
each turn one typed word into several: a brace group (-delet{e,e}), an
unquoted glob (-dele*, [-]delete), an unquoted expansion the guard cannot
resolve anywhere in a word (find <dir>$IFS-delete), a resolvable one whose
value carries whitespace or a glob character (bash globs a value after it
substitutes it), and an undecodable $'…' escape are all denied for a
command that is not read-only. A
double-quoted expansion cannot be word-split, so it keeps the narrower rule —
denied leading or inside a flag before its first =, allowed after it, which
is what keeps gh pr edit 1 --title="$T" usable. echo $A, grep $PAT f
and rm -rf $TMPDIR/x still pass. A relative path after a cd is denied, and so are
~+, ~- and ~N anywhere: the guard resolves them against its own
working directory, not the one bash will be in. The guard resolves $CLAUDE_CODE_SESSION_ID from its own
validated session context, so the per-session /advisor on/off flag
commands keep working even when the hook subprocess does not carry the
variable.
Escape hatches
/advisor off— end the modeADVISOR_DIRECT=1(env or inline) — one-off, for user-directed exceptions
Manual install
cp commands/advisor.md ~/.claude/commands/
cp rules/advisor-mode.md ~/.claude/rules/
cp hooks/advisor-guard.py hooks/advisor-posture.py ~/.claude/hooks/
cp hooks/advisor-session-start.py hooks/advisor-session-end.py ~/.claude/hooks/
# merge settings.partial.json into ~/.claude/settings.json
Cross-agent pilot compatibility
X-Plan, adrev and ETP use personal lead review by default. Explicit --cross-provider or a clear natural-language request enables the installed cross_agent_review_policy.py orchestration shim. Its early preflight checks both binaries/login status; default review needs neither. The guard permits only enumerated policy operations, with private runs under ~/.claude/cross-agent-review/<run-id>/; it does not permit direct transport scripts, arbitrary Python, reviewed-source writes or execution of recorded check argv. Trusted policy code and its resolved source cannot be overwritten through a work-product symlink.
Main-agent execution requires the installed shim and its local imports to resolve outside advisor write roots, typically through canonical repository symlinks; copied installations require delegation. A marketplace-only Claude install lacks the trusted shim. Use canonical CCGM installation in symlink mode or delegate setup/execution under normal permissions; do not broaden the guard. Optional provider errors stop that run and preserve reports/findings. The lead may separately judge delivery using evidence and normal checks, without calling the stopped run approved. Coordinator repairs require no recursive provider consensus. The optional native run has a hard two-correction-cycle cap, three exchanges per stage and no extensions. Ordinary lead review retains its separate three-round checkpoint; further lead work needs new evidence and a viable next check. Check records and host receipts are attestations, not independently proven execution/reception.
Tests
bash modules/advisor-mode/tests/test-advisor-guard.sh
bash modules/advisor-mode/tests/test-advisor-guard-differential.sh
bash modules/advisor-mode/tests/test-advisor-session.sh
test-advisor-guard.sh covers flag on/off, subagent passthrough, hatch,
work-product path allowances, the Bash allowlist/denylist, redirection
scoping, tool probes, grouping tokens, recursive substitution checking, quote
and escape handling, malformed input, and regression probes for real bypasses
found during development (newline-hidden commands, single-& chaining,
sed -i variants, git checkout -- pathspec, nested escaped backticks, a
substitution standing in for a flag or path, an escaped quote hiding a
trailing command inside a double-quoted or $'…' span, a variable standing
in for a flag or path, and a flag spelled out of reach of the literal scans
by quoting, ANSI-C quoting, or an expansion).
test-advisor-guard-differential.sh compares the guard against real bash. It
builds a throwaway sandbox and runs a generated carrier x command matrix —
every way of spelling a mutating flag against every command that acts on one
— through both the guard and bash -c, then asserts the one invariant that
matters: if bash mutated the sandbox, the guard exited 2. This is the method
that found issue #1017.
test-advisor-session.sh covers the per-session state: two sessions with
opposite modes, the session-id fallback and the fail-open when there is none,
auto-on at startup/resume/clear but not compaction, the CCGM_ADVISOR_AUTO
opt-out, legacy-file migration, SessionEnd removal, and garbage collection.
Deferred by design (v1)
- Read-cap on orchestrator context (baton's cost guard: deny oversized main-agent reads, delegate to a scout) — worth revisiting once the mode proves out; skipped so the mode never fights ordinary conversation.
- Requirements-Ledger / Stop guards — CCGM's plan/progress artifacts and etp's completion contract already cover run-to-completion.
- Model-conditional auto-enable — every session now starts in the mode; keying the default off the session's model (Fable/Opus only) is a possible follow-up.
Will install
| Path | Action | Target | Type |
|---|---|---|---|
commands/advisor.md | → | commands/advisor.md | command |
rules/advisor-mode.md | → | rules/advisor-mode.md | rule |
hooks/advisor-guard.py | → | hooks/advisor-guard.py | hook |
hooks/advisor-posture.py | → | hooks/advisor-posture.py | hook |
hooks/advisor-session-start.py | → | hooks/advisor-session-start.py | hook |
hooks/advisor-session-end.py | → | hooks/advisor-session-end.py | hook |
settings.partial.json | merge | settings.json | config |
Dependencies
Required by
No other module depends on this one.
Included in presets
Install this module
Agent prompt
Recommended for agent users -- hands the whole install off to your assistant.
Fetch https://ccgm.dev/modules/advisor-mode.md and install this module into my Claude Code setup.
Native plugin marketplace
One command via the native plugin marketplace -- additive, does not merge settings.json.
claude plugin install advisor-mode@ccgm
The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.
Files
1 further file is available as raw text.
rule (1)
rules/advisor-mode.md
# Advisor Mode: The Orchestrator Delegates, It Does Not Implement
While advisor mode is on, the main agent produces specs, reviews, and decisions, never diffs. Implementation goes to cheaper agents, the lead reviews spec compliance and quality, and fixes are delegated until the work is complete, follow-ups included. The posture is mechanical: while this session's mode flag exists, a PreToolUse hook (`advisor-guard.py`, exit 2, bypass-surviving) blocks the main agent's file edits and non-orchestration Bash. Subagent tool calls pass untouched.
The gate is hard rather than advisory because prompt-only "you never implement" postures drift into hands-on patching at friction moments, an integration mismatch or a "one-liner" fix, and capability removal is what held in every documented case. A denial is steering: delegate the mutation, never find a shell trick around it.
## Session State
- The flag is `~/.claude/advisor-mode/<session_id>`; this rule binds only while the running session's own flag exists.
- A SessionStart hook creates the flag, so every fresh, resumed, or cleared session starts in advisor mode. Opt out with `CCGM_ADVISOR_AUTO=false` in the environment or `~/.claude/.ccgm.env`.
- Compaction never re-creates a flag the session removed, so `/advisor off` survives it. SessionEnd drops the flag; SessionStart sweeps flags whose session is gone or whose transcript is untouched for three days, so a live session idle that long loses its gate and needs `/advisor on` again.
- `/advisor` toggles this session; `on|off|status` are also accepted.
## What the Orchestrator Does and Never Does
| Does (latent work) | Never does (delegated work) |
|---|---|
| Decompose work, write specs, define acceptance criteria | Edit or write source files |
| Dispatch and route agents; pick models per the ladder | Run builds, tests, or scripts itself |
| Personally review spec and quality; triage findings on evidence | Commit, push, stash, or apply patches |
| Merge reviewed and green PRs; manage issues, branches, worktrees | "Quick" inline fixes on a PR branch |
| Synthesize results; converse; answer questions directly | Bulk mechanical operations |
Trivial or conversational turns are answered directly. The mode governs the production of work, not thinking.
## The Loop
1. **Route.** Plan-shaped or investigated-issue-shaped work goes through `/etp`, which runs this loop at full ceremony. The steps below are the collapsed loop for ad-hoc work.
2. **Spec.** Objective, context (file paths, line ranges), constraints, deliverable, the why, and explicit acceptance criteria including what must still fail. Copy any safety-critical session constraints in verbatim; subagents do not inherit them.
3. **Dispatch** an `implementer` (sonnet by default) with `isolation: "worktree"`. Parallel units follow the concurrency caps in `subagent-patterns.md`. Delegation depth stays at one.
4. **Review personally**, spec compliance first, then code quality, from the spec, the diff, and fresh verification output. The implementer's rationale is not proof. Delegate builds and tests to a verifier and inspect its actual outputs. `--light-review` in ETP selects spec-only review; the two-stage review is the default.
5. **Triage** supported findings and dispatch fixes. Three fix rounds are the normal checkpoint; more needs new evidence and a viable next check. Cross-provider review is opt-in (`--cross-provider` or an explicit request); a stopped optional review is never approval.
6. **Merge** only reviewed and CI-green work, then tear down the unit's worktree. Follow-ups get the same treatment as first-class units.
## Delegation Ladder and Floor
| Tier | Work |
|---|---|
| haiku | Mechanical: bulk reads and recon, renames, extraction, tabulation, status checks |
| sonnet (default) | Implementation, tests, research; delegated reviews when requested |
| opus | Units that need frontier reasoning: architecture, security review, hard debugging |
| orchestrator | Specs, personal review, routing, triage, adjudication, synthesis |
A subagent spawn costs real fixed overhead (the full rules and CLAUDE.md context loads into it). Do not delegate work smaller than that overhead: batch small related items into one dispatch, and treat trivial textual work (answering, summarizing) as conversation. Never scale agent count when you can scale items per agent. Delegation buys context protection, orchestrator longevity, and parallelism; cost savings are modest, and micro-delegation is net-negative.
## Escape Hatches
- `/advisor off` ends the mode; the right answer when the user asks the orchestrator to implement directly.
- `ADVISOR_DIRECT=1`, in the environment or inline on one Bash command, is a one-off for a deliberate exception ("just fix it yourself"). Do not leave it exported.
## Enforcement Mechanics
- The guard tells main-agent from subagent calls by the hook input's `agent_id`/`agent_type` fields, which subagent calls carry and main-agent calls do not. Drift is asymmetric: if main-agent inputs gained the fields the guard would go inert (fail open); if subagent inputs lost them, subagents would be denied, loudly and recoverably with `/advisor off`.
- The session is identified by the hook input's `session_id`, then `CLAUDE_CODE_SESSION_ID`. A call carrying neither is allowed, the same fail-open choice.
- File writes are allowed only to orchestrator work-product paths: `~/.claude/`, temp and scratchpad roots, `~/code/plans/`, `~/code/docs/`, worktree checkouts, and plan-mode plan files. Trusted policy code is excluded even when reached through a writable-looking symlink.
- Bash is default-deny. Allowed: read-only inspection, read-only git plus branch, worktree, and pull lifecycle, and `gh` PR, issue, run, and label management including merge; redirection and scratch file-ops only into the allowed write roots; dev-tool version and identity probes (`node -v`, `wrangler whoami`) but no other arguments to those binaries. Grouping tokens are structure and their contents are checked as ordinary segments. `$(...)` and backtick substitution pass when every inner command is allowlisted (checked recursively, depth-capped, backtick bodies unescaped first), but a substitution's output may only feed a read-only command. Shells, interpreters, process substitution, and wrapper commands (`env`, `xargs`) are denied outright, except the installed cross-agent-review policy shim and its enumerated actions (that helper records checks and never executes its argv) and the installed `$HOME/.claude/bin/ccgm-persist` CLI with its start, status, done, and cancel grammar (it writes only under `~/.claude/persist/`); the installed `$HOME/.claude/bin/` output CLIs `ccgm-prose-lint`, `xplan-depgraph`, and `ccgm-output-metrics` (read-only, any arguments) and `ccgm-md-page`, allowed only when its output path (`-o OUT`, else the input with an `.html` suffix) is an `.html` path that passes the write-root check. Each of these installed CLIs and the policy shim is trusted only as the entire command (one segment, no substitution) and only when its path is absolute, starts with an unquoted `~/`, or starts with an unquoted or double-quoted `$HOME/`; a hook error denies instead of passing.
- The guard performs quote removal (including `$'…'` and `$"…"`), backslash-escape removal, and substitution of variables it can resolve before checking a word, so a flag cannot hide behind quoting. What it cannot model it denies: brace groups, unquoted globs, an unquoted expansion it cannot resolve anywhere in a word, a resolvable one whose value carries whitespace or a glob character, and undecodable `$'…'` escapes are denied for any command that is not read-only, and always in a redirect target or scratch-op path. A double-quoted expansion is allowed after a flag's first `=` (so `--title="$T"` works) and denied leading or before it. `$_` always counts as unresolvable. `gh api` mutation flags match as prefixes, and any single-dash `git branch` cluster containing `d D m M f c C` counts as a mutator.
- Open gaps: `awk` bodies and heredoc content can smuggle writes; a relative path after a `cd` is denied as unknowable; `~+`, `~-`, and `~N` are denied outright; backticks inside a double-quoted markdown body read as substitution, so pass bodies with `--body-file`. Over-denial is the accepted direction, and each denial names the delegation recipe.
See `subagent-patterns.md` for the spec format, two-stage review, status protocol, and concurrency caps; the `git-worktrees` skill for worktree lifecycle; `verification.md` for what counts as evidence.
command (1)
commands/advisor.md
--- description: Toggle advisor mode for THIS session - the orchestrator delegates implementation to cheaper agents and reviews their work; a hard PreToolUse gate blocks its own edits while the mode is on argument-hint: "[on | off | status] (bare /advisor toggles this session)" --- # /advisor - Advisor Mode Toggle Put this session into (or take it out of) **advisor mode**: the main agent becomes a pure orchestrator that specs, delegates, reviews, and merges — and is mechanically blocked from implementing directly. The full contract lives in `~/.claude/rules/advisor-mode.md`. State is **one flag file per session**: `~/.claude/advisor-mode/<session_id>`. Every command below acts on the current session only; other sessions keep whatever mode they are in. Four hooks key on it: `advisor-guard.py` (PreToolUse, exit-2 hard gate on main-agent file edits and non-orchestration Bash), `advisor-posture.py` (UserPromptSubmit, injects the posture and this session's id each turn), `advisor-session-start.py` (creates the flag at session start and garbage-collects dead sessions' flags), and `advisor-session-end.py` (removes the flag when the session ends). ## Usage ``` /advisor # TOGGLE this session: on if off, off if on /advisor on # enable explicitly (idempotent) /advisor off # disable explicitly (idempotent) /advisor status # this session's state, plus every session in the mode ``` ## Workflow **First, resolve this session's id.** Everything below needs it: 1. Run `echo $CLAUDE_CODE_SESSION_ID`. A non-empty value is the id. 2. If it is empty, use the session id from the advisor-mode posture text injected into this turn (it names the id and the flag path). 3. If neither is available, report that the session id could not be resolved and stop. Never fall back to a machine-global flag file — that is the bug this layout removed. The commands below use `$CLAUDE_CODE_SESSION_ID`; substitute the literal id when you got it from the posture text instead. Do not wrap any of them in `$(...)` or backticks — the guard denies command substitution outright. Then parse the argument: - **No argument (the default) — toggle.** Check this session's flag: `test -f ~/.claude/advisor-mode/$CLAUDE_CODE_SESSION_ID` → present means run the `off` branch; absent (exit 1) means run the `on` branch. The explicit verbs exist for scripting and for saying exactly what you mean; the bare command never requires them. - **`on`** — if the flag already exists, say so and change nothing (rewriting it would lose the original timestamp). Otherwise create it: ``` mkdir -p ~/.claude/advisor-mode date -u +'on %Y-%m-%dT%H:%M:%SZ' > ~/.claude/advisor-mode/$CLAUDE_CODE_SESSION_ID ``` (the guard allowlists writes under `~/.claude/`, so this passes even while the mode is on). Confirm in two lines: the mode is on for this session, and the one-line posture — "I orchestrate: specs, delegation, personal spec/quality review, triage, merge. Implementation goes to subagents; `/advisor off` ends the mode." - **`off`** — `rm -f ~/.claude/advisor-mode/$CLAUDE_CODE_SESSION_ID`. Confirm: `Advisor mode off for this session.` - **`status`** — report this session first, then the machine: `cat ~/.claude/advisor-mode/$CLAUDE_CODE_SESSION_ID` (on, with the timestamp it started) or note that it exits 1 when the mode is off. Then list every session currently in the mode with its start timestamp: ``` grep -H '' ~/.claude/advisor-mode/* ``` and mark which line is this session. `-H` is load-bearing — `grep` prints the filename only when it is given more than one file, and exactly one session in the mode is the common case, so without it the output has no session id to mark. When no session is in the mode the glob does not expand and the command reports `No such file or directory`: that is "no sessions in advisor mode", not an error to report as a failure. Then stop — the toggle is the whole command. Do not begin decomposing or delegating anything until the user gives actual work. ## Notes - **The mode is per session.** Turning it on or off here never touches another running session. A fresh, resumed, or cleared session starts in advisor mode by default; opt out with `CCGM_ADVISOR_AUTO=false` in the environment or in `~/.claude/.ccgm.env`. Only compaction is exempt: it never re-enables a mode the session turned off, but a resume or a `/clear` does start the mode again, so `/advisor off` lasts until the session restarts. - **Legacy state, and other ways the state directory can be missing.** The mode used to be a single file at `~/.claude/advisor-mode`. The SessionStart hook deletes that file — and a symlink standing in the same place — on first run. If `status` reports off in a session that never turned it off, check that `~/.claude/advisor-mode` is a writable directory: a leftover file, a symlink, or a permission problem each disables the auto-on silently, and the hook cannot report it (SessionStart hooks write no output). `rm ~/.claude/advisor-mode` once, or fix the permissions, and start a new session. - Flags outlive nothing: the SessionEnd hook removes this session's flag, and SessionStart sweeps flags whose session is gone. - One-off escape hatch: `ADVISOR_DIRECT=1` (env or inline on a Bash command) — for user-directed exceptions only. - If the user asks for advisor mode but then tells you to implement something directly, the honest responses are `/advisor off` first, or a one-off `ADVISOR_DIRECT=1` — not shell workarounds while the gate is up.
hook (4)
hooks/advisor-guard.py
#!/usr/bin/env python3
"""
PreToolUse hook that HARD-BLOCKS direct implementation by an advisor-mode
orchestrator.
Why: an expensive orchestrator session (Fable/Opus) in advisor mode delegates
implementation to cheaper agents and reviews their work. Advisory-only "you
never implement" prompts demonstrably fail under pressure — the documented
failure mode is the orchestrator drifting into hands-on patching exactly at
friction moments (integration fixes, small "one-liners"). This gate makes the
posture mechanical: while this session's flag file exists, the MAIN agent
cannot mutate source; a denial is steering ("delegate this"), not an obstacle.
Classification: bypass-retained. Denials use exit 2 (hard_block semantics,
inlined so the hook is dependency-free). A JSON `permissionDecision: den… hooks/advisor-posture.py
#!/usr/bin/env python3
"""
UserPromptSubmit hook: while advisor mode is on for THIS session, remind each
turn that the session is an orchestrator.
State is per session: the flag is ~/.claude/advisor-mode/<session_id>, read
from the hook input's session_id (CLAUDE_CODE_SESSION_ID as the fallback), so
another session's mode never injects here. The injection also names the
session id, which is what /advisor uses to find its own flag when the
environment variable is missing.
The guard (advisor-guard.py) enforces the posture mechanically; this injection
explains it so the model delegates instead of fighting denials. Adapted from
baton's orchestrator-mode injection pattern. One JSON parse and one stat()
per prompt when the mode is off.
"""
import json
import os
import re
import sys
SESSION_ID_ENV = "CLAUDE_CODE_SESSION_ID"
# Session ids are uuids; anything else cannot name a flag file. Rejecting
# separators and dot-entries keeps the flag inside the state directory.
SESSION_ID_RE = re.compile(r"[A-Za-z0-9._-]+")
CONTEXT = (
"advisor mode is ON — you are the orchestrator: a PreToolUse hook blocks "
"your direct file edits and non-orchestration Bash. Your deliverable is "
"the spec, not the diff: decompose the work, dispatch implementer "
"subagents (four-field spec with file paths and the objective's why; "
"isolation: worktree; sonnet by default, haiku for mechanical work, opus "
"for hard units), personally review spec compliance and quality with "
"explicit success criteria, run normal repository checks, and delegate fixes "
"(max 3 rounds, then escalate). Cross-provider review is opt-in and bounded; a stopped optional review is never approval. Route plan- or issue-shaped work through "
"/etp. Batch small related items into one dispatch — a subagent spawn "
"costs real overhead — and copy any safety-critical session constraints "
"into every spec, because subagents do not inherit them. Trivial or "
"conversational turns: just answer directly. A guard denial means "
"delegate it, never shell-trick around it. /advisor off ends the mode."
)
TRAILER = (
" The mode is per session: this session's id is {sid}, and its flag is "
"~/.claude/advisor-mode/{sid}. Other sessions are unaffected."
)
def session_id(data):
"""This turn's session id: hook input first, environment as fallback."""
for candidate in (data.get("session_id"), os.environ.get(SESSION_ID_ENV)):
if not isinstance(candidate, str):
continue
candidate = candidate.strip()
if candidate in (".", "..") or not SESSION_ID_RE.fullmatch(candidate):
continue
return candidate
return None
def main():
try:
data = json.load(sys.stdin)
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
sid = session_id(data)
if not sid:
sys.exit(0)
flag = os.path.join(
os.path.expanduser("~"), ".claude", "advisor-mode", sid)
if not os.path.isfile(flag):
sys.exit(0)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": CONTEXT + TRAILER.format(sid=sid),
},
"suppressOutput": True,
}))
sys.exit(0)
if __name__ == "__main__":
main()
hooks/advisor-session-start.py
#!/usr/bin/env python3
"""
SessionStart hook: put THIS session into advisor mode, and clean up flags left
behind by sessions that are gone.
Advisor-mode state is one flag file per session,
`~/.claude/advisor-mode/<session_id>`, so one session's mode never binds
another's. This hook owns three jobs, in order:
1. Migration — the mode used to be a single regular file at
`~/.claude/advisor-mode`. If that file (or a symlink where the
directory belongs) is still there, delete it; the path becomes the
state directory. Leaving it would be silent and total: makedirs,
listdir and the flag write all fail, and every session runs un-gated.
2. Garbage collection — a session that crashed never ran SessionEnd, so its
flag would sit there forever. A flag whose session has no transcript
under `~/.claude/projects/*/<session_id>.jsonl` is removed once it is
more than an hour old (the grace period keeps a session that started
seconds ago, and has not written its transcript yet, from being swept).
A flag whose transcript has not been touched in three days is removed
too — including a session that is still alive but has been idle that
long, which loses its gate until it runs `/advisor on` again (recorded
as a known gap in rules/advisor-mode.md). The current session's flag is
never touched; when no session id resolves there is no current session
to exempt, so GC runs over every flag, which only reaches flags the
rules above would sweep anyway.
3. Auto-on — create this session's flag, so every session starts in advisor
mode. Opt out with CCGM_ADVISOR_AUTO=false in the environment or in
`~/.claude/.ccgm.env` (environment wins; unset means on), matching
CCGM_AUTO_UPDATE_CHECK. GC still runs when the
auto-on is opted out.
`source == "compact"` skips the auto-on: compaction is mid-session continuity,
and re-creating the flag there would silently undo an explicit `/advisor off`.
An existing flag is never rewritten, so a resume keeps its original timestamp.
Never raises, never writes to stdout (the per-turn posture injection is
advisor-posture.py's job), always exits 0.
"""
import glob
import json
import os
import re
import sys
import time
SESSION_ID_ENV = "CLAUDE_CODE_SESSION_ID"
AUTO_ENV = "CCGM_ADVISOR_AUTO"
FALSE_VALUES = ("false", "0", "no", "off")
# Session ids are uuids; anything else cannot name a flag file. Rejecting
# separators and dot-entries keeps the flag inside the state directory.
SESSION_ID_RE = re.compile(r"[A-Za-z0-9._-]+")
NO_TRANSCRIPT_GRACE_SECONDS = 60 * 60 # 1 hour
STALE_TRANSCRIPT_SECONDS = 3 * 24 * 60 * 60 # 3 days
def home():
return os.path.expanduser("~")
def state_dir():
return os.path.join(home(), ".claude", "advisor-mode")
def session_id(data):
"""This session's id: hook input first, environment as fallback."""
for candidate in (data.get("session_id"), os.environ.get(SESSION_ID_ENV)):
if not isinstance(candidate, str):
continue
candidate = candidate.strip()
if candidate in (".", "..") or not SESSION_ID_RE.fullmatch(candidate):
continue
return candidate
return None
def auto_on_enabled():
"""CCGM_ADVISOR_AUTO: environment wins over .ccgm.env; unset means on.
This is the project's first opt-OUT flag in `.ccgm.env`, so the parser is
deliberately more tolerant than the opt-in flags' `startswith("KEY=")`:
a line the operator meant as "off" that fails to parse turns the mode ON
against their wish. `export`, a trailing comment, spaces around `=`, and
quotes are all accepted, and the last matching line wins, as a shell
would read the file.
"""
value = os.environ.get(AUTO_ENV)
if isinstance(value, str) and value.strip():
return value.strip().lower() not in FALSE_VALUES
setting = None
env_file = os.path.join(home(), ".claude", ".ccgm.env")
try:
with open(env_file) as handle:
for line in handle:
line = line.split("#", 1)[0].strip()
if line.startswith("export "):
line = line[len("export "):].strip()
key, sep, raw = line.partition("=")
if sep and key.strip() == AUTO_ENV:
setting = raw.strip().strip("'\"").lower()
except (OSError, IOError):
pass
if setting is None:
return True
return setting not in FALSE_VALUES
def migrate_legacy_file(directory):
"""The pre-per-session state was a regular file at this exact path.
A symlink is removed too: unlinking it never touches its target, and a
symlink standing where the state directory belongs is the same silent
dead end as the legacy file (makedirs raises, listdir raises, the flag
write raises, and the mode is off everywhere with no signal).
"""
try:
if os.path.islink(directory) or os.path.isfile(directory):
os.remove(directory)
except OSError:
pass
def collect_garbage(directory, current_sid):
"""Drop flags whose session is gone. Never touches the current session."""
now = time.time()
try:
names = os.listdir(directory)
except OSError:
return
for name in names:
if name == current_sid:
continue
flag = os.path.join(directory, name)
try:
if not os.path.isfile(flag):
continue
transcripts = glob.glob(os.path.join(
home(), ".claude", "projects", "*", name + ".jsonl"))
if not transcripts:
# A session that just started may not have a transcript yet.
if now - os.path.getmtime(flag) > NO_TRANSCRIPT_GRACE_SECONDS:
os.remove(flag)
continue
newest = max(os.path.getmtime(t) for t in transcripts)
if now - newest > STALE_TRANSCRIPT_SECONDS:
os.remove(flag)
except OSError:
continue # a racing writer or an unreadable entry: leave it
def enable(directory, sid):
"""Create this session's flag. An existing flag keeps its timestamp."""
flag = os.path.join(directory, sid)
if os.path.exists(flag):
return
stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
try:
with open(flag, "w") as handle:
handle.write("on %s\n" % stamp)
except (OSError, IOError):
pass
def main():
try:
data = json.load(sys.stdin)
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
directory = state_dir()
migrate_legacy_file(directory)
try:
os.makedirs(directory)
except OSError:
pass # already there, or it cannot be created — nothing else to do
sid = session_id(data)
collect_garbage(directory, sid)
if not sid:
return
if data.get("source") == "compact":
return
if not auto_on_enabled():
return
enable(directory, sid)
if __name__ == "__main__":
try:
main()
except Exception:
pass # a session must never fail to start because of this hook
sys.exit(0)
hooks/advisor-session-end.py
#!/usr/bin/env python3
"""
SessionEnd hook: drop this session's advisor-mode flag.
Advisor-mode state is one flag file per session,
`~/.claude/advisor-mode/<session_id>`. Removing it when the session ends keeps
the state directory from filling with dead sessions. A session that dies
without firing SessionEnd is caught later by advisor-session-start.py's
garbage collection.
Never raises, always exits 0.
"""
import json
import os
import re
import sys
SESSION_ID_ENV = "CLAUDE_CODE_SESSION_ID"
# Session ids are uuids; anything else cannot name a flag file. Rejecting
# separators and dot-entries keeps the flag inside the state directory.
SESSION_ID_RE = re.compile(r"[A-Za-z0-9._-]+")
def session_id(data):
"""This session's id: hook input first, environment as fallback."""
for candidate in (data.get("session_id"), os.environ.get(SESSION_ID_ENV)):
if not isinstance(candidate, str):
continue
candidate = candidate.strip()
if candidate in (".", "..") or not SESSION_ID_RE.fullmatch(candidate):
continue
return candidate
return None
def main():
try:
data = json.load(sys.stdin)
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
sid = session_id(data)
if not sid:
return
flag = os.path.join(
os.path.expanduser("~"), ".claude", "advisor-mode", sid)
try:
os.remove(flag)
except OSError:
pass # already gone, or never created
if __name__ == "__main__":
try:
main()
except Exception:
pass
sys.exit(0)
config (1)
settings.partial.json
Merged into ~/.claude/settings.json -- a fragment, not a replacement.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|MultiEdit|Write|NotebookEdit|mcp__filesystem__write_file|mcp__filesystem__edit_file|mcp__filesystem__move_file|Bash",
"hooks": [
{
"type": "command",
"command": "python3 $HOME/.claude/hooks/advisor-guard.py",
"timeout": 5000
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 $HOME/.claude/hooks/advisor-posture.py",
"timeout": 5000
}
]
}
],
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "python3 $HOME/.claude/hooks/advisor-session-start.py",
"timeout": 5000
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "python3 $HOME/.claude/hooks/advisor-session-end.py",
"timeout": 5000
}
]
}
]
}
}