Manager
Manager mode has TWO independent layers. Keep them straight:
- SOFT codewords (
++m / ++a / ++rr / ++r) — autonomous, hook-driven, ALWAYS fire. A UserPromptSubmit hook (hooks/manager-prompt.mjs) watches every prompt; when it sees a codeword it injects the matching block as additionalContext for that one turn. This is NOT enabled/disabled by this skill — it works regardless of skill state. The skill only explains it (status) and customizes its TEXT (edit/purge).
Detection (longest-prefix first within the review group):
++m → Manager mode. PLAN-AWARE: when the session is in plan mode (permission_mode === 'plan') it injects the planmode block (full + plan addon — writes the task graph, uses the tasks tool); otherwise the plain full delegate-everything block. There is NO separate ++mp codeword.
++a → Architecture-first directive (architect). Injects [DIRECTIVE: ARCHITECTURE-FIRST] before implementation — delegate an architecture pass that fits the project's existing architecture, patterns and rules; robust, scalable, and SIMPLE (no over-engineering); find the closest well-built counterpart in the repo and take its principles (additive to conventions/rules, not a replacement), clean seams. Independent group — combines with ++m and the review group. Mode-agnostic: same block in plan and normal mode (in plan mode it is written into the plan).
++rr → Regression Review discipline (review-regression) — after each significant phase: no regression + project standard + correctness; two-phase review→double-check→fix; final cross-review at task end. Tested before ++r.
++r → Review discipline (review-double) — two-phase multi-agent review→double-check→fix after each significant change; codeword-only (no ambient/wall injection).
- When the HARD wall is ON, the Manager (full) block is ALSO auto-injected on EVERY turn — no codeword needed. Codewords and wall injection are independent.
- HARD wall — opt-in, this skill only, PER-PROJECT, INSTALLED-INTO-THE-PROJECT, persistent. The wall is NOT a plugin hook.
install does two things: it installs a self-contained PreToolUse guard into THIS project (copies the guard file + idempotently registers it in <cwd>/.claude/settings.local.json) and, only after the user has explicitly confirmed arming (P1 arm-confirmation gate — an AskUserQuestion answered "Yes, arm it now", or explicit wording like "enable the hard wall"/"включи хард уолл" already in the user's own prompt; the bare verb install/установи and any autonomy phrasing like "decide everything yourself"/"автономно" NEVER count as confirmation), arms it by flipping state.hard=true. Declining still installs the guard, leaving state.hard=false. The registered guard then physically denies mutating tools (Write/Edit/Bash/WebFetch/...) in the main session, leaving only delegate/read/track. Subagents stay fully free (agent_id linchpin). enable re-arms an already-installed wall — same confirmation gate, decline aborts with nothing changed; disable only flips state.hard=false — registration stays, the guard no-ops. uninstall removes the registration and the copied guard; purge also deletes the state file and the prompt overrides. The wall lives in project state + project settings, defaults OFF, persists until disable/uninstall. There is no codeword for the wall.
The two layers are orthogonal: the wall enforces delegation by removing hands; the codewords/prompt-text shape the Manager mindset. Either can be used alone.
INSTALL-ONCE + STATE-GATE (the safety crux): the guard is registered once in settings.local.json (a personal, gitignored file) but is gated at runtime by project state.json {hard}. Registration is the persistent plumbing; state.hard is the live kill-switch. This split exists because while the wall is armed it DENIES Edit/Bash on arbitrary files — so disable must NOT touch settings.local.json (that edit would be blocked). Instead disable flips state.json with the ONE Bash shape the guard self-exempts, so the state flip always succeeds even at level strict. Conclusion: state.json is the runtime kill-switch; registration is harmless inert plumbing left in place.
THE EXEMPT COMMAND (memorize this shape — nothing else gets through an armed wall):
node <ABS project root>/.claude/brewtools/manager/manager-state.mjs set hard=false
The guard exempts it only when ALL of these hold: the command starts with node , the FIRST argument after node resolves (realpath) to the helper this project actually installed — <root>/.claude/brewtools/manager/manager-state.mjs, or the plugin's own hooks/lib/manager-state.mjs next to the guard — there is no shell operator outside quotes, no $ expansion, no eval flag (-e/--eval/-p/--print/--input-type/--require/--import/--loader), and the remaining arguments are the helper's own CLI (get | set hard=<true|false> level=<strict|balanced> mcpAllow=<mcp__srv__tool[,...]|> [--cwd DIR]). No BT_ROOT= prelude, no && echo, no || echo, no test -f — every one of those is a shell operator and turns the exemption OFF. A file merely named manager-state.mjs elsewhere on disk is NOT exempt: the anchor is the absolute installed path, not the filename or a path suffix. install/upgrade copy the helper into the project precisely so this command needs no path resolution.
What the guard actually enforces (read this before you need it)
| Property |
Behaviour |
| Who is walled |
The MAIN session only. Subagents are free BY DESIGN — the discriminator is agent_id in the PreToolUse payload, present only for subagent calls. A claude --agent <name> main session carries agent_type without agent_id and IS walled. |
| Project root |
Resolved as CLAUDE_PROJECT_DIR → upward walk for .git/.claude → hook cwd, plus the guard's own installed directory. State is found from ANY nested working directory; a deep cwd no longer silently disables the wall. |
| Fail-closed |
An unparseable PreToolUse payload, an internal guard error, or an installed manager directory whose state.json is missing/corrupt all DENY the main session (at strict semantics) instead of passing through. Subagents still pass. |
balanced Bash |
A strict allowlist of exact binaries (ls cat pwd which head tail wc grep rg date whoami basename dirname realpath test [ jq echo find git gh node) plus per-binary flag vetting: rg --pre/--pre-glob/--search-zip, find -exec/-ok/-delete/-fprint*, git -c/--exec-path/--upload-pack/--ext-diff, and node anything other than --check are DENIED. env is not on the list at all — it is a universal exec wrapper. Any >/< redirection, $(...) or backtick anywhere denies the whole command. |
strict Bash |
Everything above is denied too; only the exempt state CLI runs. |
| MCP |
Classified on the tool segment after the second __, so a server named search cannot launder mcp__search__destroy_all. Default-deny per token: an unrecognised verb or noun → denied, and an ambiguous verb (query/resolve) reads ONLY inside a docs/reference name (query-docs, resolve-library-id), so mcp__sqlite__query, mcp__sqlite__query_table and mcp__linear__resolve_issue are all denied — those write on a DB server or tracker. mcpAllow is the escape hatch. |
mcpAllow |
Optional state key, the escape hatch for a false MCP denial. Entries are an exact scoped name mcp__server__tool or a whole-server prefix mcp__server__*; consulted BEFORE the classifier and at balanced ONLY — strict denies all MCP, allowlisted or not. A malformed value allows nothing and never breaks state. |
Recovery, in order of preference.
node <ABS root>/.claude/brewtools/manager/manager-state.mjs set hard=false — works at every level, including when state.json is corrupt (it rewrites the file).
- A genuinely read-only MCP tool denied by the classifier — allowlist it (
balanced only), same self-exempt command shape, quote the value so the shell keeps *:
node <ABS root>/.claude/brewtools/manager/manager-state.mjs set 'mcpAllow=mcp__semble_code__*,mcp__github__get_file' — the list is replaced wholesale, one invalid entry writes nothing (exit 2), and set 'mcpAllow=' clears it.
- Delegate:
Task is always allowed and subagents are unwalled, so a subagent can run /brewtools:manager-setup upgrade or repair state for you.
- Two residual cases need action OUTSIDE the session, and there is no in-session workaround — do not go hunting for one:
- Claude Code changes the PreToolUse payload shape so the guard cannot parse it. Every main-session mutation is then denied. Fix: quit and delete the
brewtools-manager-guard entry from .claude/settings.local.json in an editor.
- Deleting the whole
.claude/brewtools/ tree disarms the wall (no manager directory = never installed). That is the documented consequence of a manual rm -rf, not a way to disable the wall — use disable or uninstall, which keep settings and files consistent.
Prompt contract
Position 1 of $ARGUMENTS is a free-form prompt (RU/EN) — modes and flags are optional and may
follow in any order. Nobody types keys: resolve the action + scope FROM the prompt via
references/intent-routing.md (P0 table below is the same routing, EN/RU keyword split).
- Strip flags. An explicit action token anywhere wins outright, no scoring.
- Else score actions by distinct whole-word keyword hits (P0 table). Highest unique score wins.
Tie with a destructive action (
purge) -> AskUserQuestion; tie with status -> status;
tie of two mutating actions -> the keyword appearing first; all zero -> status.
- Empty arguments ->
status; ask ONE scoping AskUserQuestion only when the answer changes
what gets written or armed. status asks nothing.
- Outcome-changing ambiguity (incl.
hard-one-shot vs manager-run, enable vs disable) -> ONE
AskUserQuestion (max 4 questions) BEFORE any work — this is P1.
- Prose that is not an action/id/path is still input: extract the task from
<task> в хард режиме / <task> от роли менеджера rather than treating the first word as a positional id.
Then print this block ONCE, after P1 and before the first action (P2). A read-only status
prints it immediately before its report:
PLAN — brewtools:manager-setup
INPUT: <arguments verbatim, or "(empty)">
MODE: <resolved action> — <explicit | matched keyword: X | default>
SCOPE: <resolved paths / level / task> — LAYER: <codewords (soft, always-on, hook-driven) |
HARD wall (opt-in, this project, PreToolUse guard)> — name which layer this action touches
DO: <2-5 imperative bullets>
RESULT: <what the user ends up holding>
Labels are literal; values follow the conversation language. install/upgrade/enable/
disable/uninstall/purge/level touch the HARD-wall layer; edit touches the codewords
layer (prompt text only); hard-one-shot touches BOTH (arms/disarms the wall AND runs the task
under the codewords contract); manager-run/inline-run touch only the codewords layer.
Robustness Rules
| Rule |
Applies |
Every Bash call ends with && echo "✅ ..." || echo "❌ FAILED ..." |
ALL except the bare exempt state-write command (see the crux box) — appending && echo there makes the armed wall deny it |
The HARD wall (state.hard) is PROJECT scope ONLY — there is no global wall. Always writeState('project', ...) for hard/level |
install/enable/disable/level/hard-one-shot |
The wall is installed INTO the project, not shipped as a plugin hook. install/upgrade copy the guard + register it in <cwd>/.claude/settings.local.json; enable/disable flip state only; uninstall/purge deregister |
install/upgrade/enable/disable/uninstall/purge |
All settings.local.json mutations go through a node Bash block (read-merge-atomic-write), NEVER the Edit tool — the Edit tool may be blocked by an armed wall, and we must not depend on it |
install/upgrade/uninstall/purge |
State writes go through writeState(scope, partial, cwd) (atomic: lockfile + tmp + rename) — never write state.json by hand |
P2 |
State reads go through resolveState(cwd); prompts via resolvePrompt(mode, cwd, root) / resolvePromptPath(scope, mode, cwd) |
P2, status |
| Never reimplement resolution logic — always call the helpers |
ALL |
GLOBAL prompt-override paths (~/.claude/manager/prompts/*) are PROTECTED for Write/Edit — write ONLY via the Node helper through Bash. Project prompt overrides are plain writes (still prefer helper) |
edit/purge |
Scope, said once so it is never confused
| Thing |
Scope |
Files |
Wall state {hard, level} + optional mcpAllow (runtime kill-switch) |
PROJECT ONLY |
<cwd>/.claude/brewtools/manager/state.json |
| Wall registration (persistent plumbing) |
PROJECT ONLY |
<cwd>/.claude/settings.local.json (PreToolUse * entry) + copied guard <cwd>/.claude/brewtools/manager/hardmode-guard.mjs |
Soft default mode field (informational) |
project state |
same state.json |
Prompt-text overrides (edit/purge) |
project or global (separate files) |
project: <cwd>/.claude/brewtools/manager/prompts/<mode>.md · global: ~/.claude/manager/prompts/<mode>.md |
"Wall scope" is fixed (project). "Prompt-text override scope" is a different, independent axis that edit/purge may target globally. Do not let --scope global leak onto the wall — it has no meaning there.
BT_ROOT Resolver
The plugin root is resolved from the skill's OWN directory (the CLAUDE_SKILL_DIR prompt substitution), never from CLAUDE_PLUGIN_ROOT -- that env var is not exported to a skill's Bash tool. Every Bash block resolves BT_ROOT this way (no hardcoded version):
SD="${CLAUDE_SKILL_DIR}"
if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi
[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; }
test -f "$BT_ROOT/hooks/lib/manager-state.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }
Paths (use $BT_ROOT literally in Bash):
- State helper:
$BT_ROOT/hooks/lib/manager-state.mjs — exports resolveState, writeState, resolveStatePath; also a CLI: node <path>/manager-state.mjs get|set hard=<true|false> level=<strict|balanced> mcpAllow=<mcp__srv__tool[,...]|> [--cwd DIR]
- Prompt helper:
$BT_ROOT/hooks/lib/manager-prompts.mjs — exports resolvePrompt, resolvePromptPath
- Guard source (shipped, self-contained, NOT in plugin
hooks.json): $BT_ROOT/hooks/hardmode-guard.mjs — install/upgrade copy this into the project
- Plugin default blocks:
$BT_ROOT/skills/manager-setup/references/<mode>.md (full.md, planmode.md)
- Wall policy + canonical status text:
$BT_ROOT/skills/manager-setup/references/hard.md — Read it for the install model, status explainer and the allowlist details.
Project install targets (resolved from process.cwd()):
- Copied guard:
<cwd>/.claude/brewtools/manager/hardmode-guard.mjs
- Copied state helper:
<cwd>/.claude/brewtools/manager/manager-state.mjs — the off-switch CLI. Copied so disable/level are a fixed path needing no BT_ROOT resolution (resolution needs shell operators, which the armed wall denies)
- Registration:
<cwd>/.claude/settings.local.json — a PreToolUse matcher "*" entry whose command runs node <ABS path to copied guard>. Tagged with marker brewtools-manager-guard so uninstall/purge can find it.
Resolution chains (must match helpers exactly)
| What |
project |
→ global |
→ default |
State mode (informational) |
<cwd>/.claude/brewtools/manager/state.json |
~/.claude/manager/state.json |
mode:'full' |
Wall flags {hard, level, mcpAllow} |
<cwd>/.claude/brewtools/manager/state.json |
(no global — PROJECT-ONLY) |
{hard:false, level:'balanced', mcpAllow:[]} |
Prompt text <mode> |
<cwd>/.claude/brewtools/manager/prompts/<mode>.md |
~/.claude/manager/prompts/<mode>.md |
$BT_ROOT/skills/manager-setup/references/<mode>.md |
The wall flags (hard/level) are resolved PROJECT-ONLY in code — the global state.json does NOT enable the wall. The skill writes them to project scope only. (The informational mode field may still resolve from global; hard/level do not.)
P0: Resolve Intent
Parse $ARGUMENTS (or the user's NL prompt, RU+EN) into { action, scope, mode, level, task } using references/intent-routing.md — Read and follow it.
Actions, canonical order: status, install, upgrade, enable, disable, uninstall, purge, plus the extras level <strict|balanced>, edit, and the run actions hard-one-shot, manager-run, inline-run.
| Action |
EN keywords |
RU keywords |
Mutates? |
Resolves |
status |
(empty), status |
статус, что сейчас |
no |
the main explainer, and the default |
install |
install (no task) |
установи, поставь стену |
yes |
INSTALL + ARM the HARD wall for this project |
upgrade |
upgrade |
обнови, перекопируй гард |
yes |
re-copy the guard + re-register from the CURRENT plugin version; hard/level preserved |
enable |
enable, on, arm (no task) |
вкл, включи |
yes |
ARM an installed wall (state flip only). NOT registered yet → treat as install |
disable |
disable, off, disarm |
выкл, выключи, стена выкл, стену выключи |
yes |
DISARM the wall (state only; registration stays) |
uninstall |
uninstall, teardown, remove hook |
снеси стену, удали хук, деинсталлируй |
yes |
DEREGISTER the wall from settings.local.json + delete the copied guard (auto-disarms first). State and prompt overrides are KEPT |
purge |
purge |
вычисти, снеси всё, верни дефолт, сброс |
yes, destructive |
uninstall + delete state.json AND the prompt-text override(s) |
level strict |
level strict |
режим строгий |
yes |
wall strictness = strict |
level balanced |
level balanced |
режим сбалансированный |
yes |
wall strictness = balanced |
edit |
edit |
поправь промт |
yes (prompt-text only) |
prompt-text only (Manager prompt text) |
hard-one-shot |
<task> in hard mode |
<task> в хард режиме |
yes (arms, auto-reverts) |
has a REAL task + hard marker |
manager-run |
<task> as manager |
<task> от роли менеджера |
no (wall untouched) |
run task in manager role, wall untouched |
inline-run |
bare task, no control verb, no marker |
— |
no (wall untouched) |
gentle default for a bare task |
on / off / reset / setup / remove are REMOVED as command words. on and off survive only as free-text synonyms routed to enable / disable above; reset routes to purge. Never print them as commands.
Arming is never automatic. install, enable, and hard-one-shot are the only actions that can
write state.hard=true. Resolving the action (even from an explicit keyword like install, or
autonomy phrasing like "autonomous, decide everything yourself"/"автономно выбирай сам") is NOT
confirmation to arm — that confirmation is a separate, mandatory P1 gate. See P1 below.
Prompt-text override scope (ONLY for edit/purge): default = project. --scope global OR глобально / globally → global. This scope does NOT apply to install/upgrade/enable/disable/uninstall/level (those are project-only).
P1: Echo + Disambiguate
Print ONE line stating the resolved intent, e.g.:
Understood: install + arm the hard wall (project), level=balanced
If the action is ambiguous or signals conflict (e.g. enable + disable, a task that might be hard-one-shot vs manager-run, control implied but no verb) → AskUserQuestion with the candidate actions as options. Otherwise proceed.
Distinguish carefully: hard-one-shot (task + "в хард режиме"/"in hard mode") flips the wall and auto-reverts; manager-run (task + "от роли менеджера"/"as manager") never touches the wall, discipline by prompt only. If both/neither marker is present and a task exists, ask.
Arm-confirmation gate (unconditional — not only on ambiguity). install, enable, and
hard-one-shot are the only actions that can write state.hard=true. Before P2 runs any of them,
check whether the user's OWN prompt already carries EXPLICIT confirming wording: the words "hard
wall" / "хард уолл" / "стену" TOGETHER with an enable/arm verb ("enable"/"arm"/"включи"/"заarmи"),
e.g. "enable the hard wall", "arm the wall", "включи Hard Wall", "включи хард уолл", "заarmи стену".
The bare skill verb alone (install/установи/enable/включи with no "wall" wording) does
not count, and no autonomy-permission phrasing ever counts ("autonomous, decide everything
yourself", "автономно", "выбирай сам" — these NEVER satisfy the gate). If explicit wording is
present, treat the arm as already confirmed and skip the question. Otherwise ask exactly ONE
AskUserQuestion before P2:
install: "Arm the HARD wall in this project now? It will block Write/Edit/Bash in the MAIN
session (subagents stay free) until disabled." — options Yes, arm it now / No, just install (stay disarmed).
enable / hard-one-shot: the same question, options Yes, arm it now / No, cancel.
Record the outcome as ARM (true/false) for P2:
install proceeds either way — it always copies the guard and registers the hook; ARM only
decides whether state.hard is written true or false. ARM=false → report "installed but
NOT armed — run enable (or confirm next time) to arm it."
enable / hard-one-shot: ARM=false aborts the whole action before running anything — do not
touch state.hard, report plainly that nothing changed.
P2: Execute
Print the ## Prompt contract PLAN block first — INPUT/MODE from P0, SCOPE naming which layer
(codewords vs HARD wall, or both for hard-one-shot) — before running the mapped section below.
status prints the same block immediately before its report instead of before a mutation.
Sections below map 1:1 to action: install, upgrade, enable, disable, uninstall, purge, level, status, edit, and the three run actions.
install (INSTALL + ARM the HARD wall — project only)
install is a five-step sequence: (1) set arm state per the P1 confirm gate (ARM), (2) copy the guard into the project, (3) idempotently register it in settings.local.json, (4) turn the task-graph tools on in that same file, (5) report whether a /reload is needed. All five run in ONE node Bash block so the registration is atomic and self-contained. ARM (true/false) is resolved by the P1 arm-confirmation gate — substitute it into the block below before running, same as LEVEL in the level action. The block:
- sets
state.hard = (ARM === 'true') via writeState('project', {hard:arm}) — arms only when P1 confirmed,
- copies
$BT_ROOT/hooks/hardmode-guard.mjs → <cwd>/.claude/brewtools/manager/hardmode-guard.mjs and $BT_ROOT/hooks/lib/manager-state.mjs → <cwd>/.claude/brewtools/manager/manager-state.mjs (both overwritten on EVERY install, so plugin updates propagate; the second one is the off-switch CLI),
- read-merge-atomic-writes
<cwd>/.claude/settings.local.json, adding a PreToolUse matcher "*" entry that runs node <ABS copied-guard path> tagged brewtools-manager-guard, but ONLY if no entry already points at the manager guard (idempotent — running twice = ONE entry),
- in that SAME merge sets
env.CLAUDE_CODE_ENABLE_TODO_TOOLS = "1" when Claude Code is >= 2.1.233 — creating the env object if absent, preserving every other key. This is unconditional and never asks: from 2.1.233 TaskCreate/TaskUpdate/TaskGet/TaskList are gated OFF by default, and the manager framework has no task graph without them. Below 2.1.233 the var does nothing and the tools are on anyway, so the write is skipped and the block says so,
- prints
newlyRegistered and todoTools so you know whether to surface the /reload note and what happened to the task tools.
EXECUTE using Bash tool:
SD="${CLAUDE_SKILL_DIR}"
if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi
[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; }
test -f "$BT_ROOT/hooks/hardmode-guard.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }
ROOT=$(if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then printf %s "$CLAUDE_PROJECT_DIR"; elif r=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$r" ]; then printf %s "$r"; else d=$PWD; while [ "$d" != "/" ]; do if [ -d "$d/.git" ] || [ -d "$d/.claude" ]; then printf %s "$d"; break; fi; d=$(dirname "$d"); done; fi)
[ -n "$ROOT" ] || { echo "❌ cannot resolve project root — looked for CLAUDE_PROJECT_DIR, git toplevel, then .git/.claude above $PWD; nothing written"; exit 1; }
CCVER=$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)
ARM=<true|false>
node --input-type=module -e "
import {writeState} from '${BT_ROOT}/hooks/lib/manager-state.mjs';
import fs from 'node:fs'; import path from 'node:path';
const cwd = '${ROOT}';
const arm = '${ARM}' === 'true';
const src = '${BT_ROOT}/hooks/hardmode-guard.mjs';
const dir = path.join(cwd, '.claude', 'brewtools', 'manager');
const guard = path.join(dir, 'hardmode-guard.mjs');
const helper = path.join(dir, 'manager-state.mjs');
const settings = path.join(cwd, '.claude', 'settings.local.json');
const TAG = 'brewtools-manager-guard';
// Task graph: CC 2.1.233+ gates TaskCreate/Update/Get/List off unless env.CLAUDE_CODE_ENABLE_TODO_TOOLS
// is set. Numeric compare, never string. Below 2.1.233 the key is a no-op, so skip the write.
const ccVer = '${CCVER}';
const geVersion = (v, t) => { const a = String(v).split('.').map(n => parseInt(n, 10)); return a.length === 3 && !a.some(Number.isNaN) && (a[0] - t[0] || a[1] - t[1] || a[2] - t[2]) >= 0; };
const todoToolsGated = geVersion(ccVer, [2,1,233]);
const todoTools = todoToolsGated ? 'enabled (CC ' + ccVer + ')'
: ccVer ? 'skipped — CC ' + ccVer + ' predates the 2.1.233 gate, task tools are on by default'
: 'skipped — could not read the Claude Code version; on 2.1.233+ set env.CLAUDE_CODE_ENABLE_TODO_TOOLS=1 by hand';
// 1. arm only if P1 confirmed
await writeState('project', {hard:arm}, cwd);
// 2. copy guard + off-switch CLI (overwrite each install)
fs.mkdirSync(dir, {recursive:true});
fs.copyFileSync(src, guard);
fs.copyFileSync('${BT_ROOT}/hooks/lib/manager-state.mjs', helper);
// 3. idempotent register, under the settings lock
const lock = settings + '.lock';
fs.mkdirSync(path.dirname(settings), {recursive:true});
let held = false;
for (let i = 0; i < 50 && !held; i++) {
try { fs.mkdirSync(lock); held = true; }
catch {
try { if (Date.now() - fs.statSync(lock).mtimeMs > 30000) { fs.rmSync(lock, {recursive:true, force:true}); continue; } } catch {}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
}
}
if (!held) { console.error('ABORT: ' + lock + ' is held by another setup skill — retry in a moment; nothing was written'); process.exit(1); }
let newlyRegistered = false;
try {
let cfg = {};
try { cfg = JSON.parse(fs.readFileSync(settings,'utf8')); }
catch (e) { if (e.code !== 'ENOENT') { console.error('ABORT: ' + settings + ' unreadable or invalid JSON (' + e.message + ') — fix it by hand; nothing was written'); process.exit(1); } }
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) { console.error('ABORT: ' + settings + ' is not a JSON object — fix it by hand; nothing was written'); process.exit(1); }
cfg.hooks = (cfg.hooks && typeof cfg.hooks==='object') ? cfg.hooks : {};
const arr = Array.isArray(cfg.hooks.PreToolUse) ? cfg.hooks.PreToolUse : [];
const has = m => Array.isArray(m.hooks) && m.hooks.some(h => typeof h.command==='string' && (h.command.includes(TAG) || h.command.includes('hardmode-guard.mjs')));
if (!arr.some(has)) {
arr.push({ matcher:'*', hooks:[{ type:'command', command:\`node \"\${guard}\" # \${TAG}\`, timeout:5 }] });
newlyRegistered = true;
}
cfg.hooks.PreToolUse = arr;
// 4. task graph on, same merge — idempotent, one key, every other setting preserved.
if (todoToolsGated) { cfg.env = (cfg.env && typeof cfg.env==='object' && !Array.isArray(cfg.env)) ? cfg.env : {}; cfg.env.CLAUDE_CODE_ENABLE_TODO_TOOLS = '1'; }
if (fs.existsSync(settings)) fs.copyFileSync(settings, settings + '.bak');
const tmp = settings + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
fs.renameSync(tmp, settings);
} finally { fs.rmSync(lock, {recursive:true, force:true}); }
console.log(JSON.stringify({armed:arm, guard, helper, settings, newlyRegistered, todoTools, root:cwd}));
" && echo "✅ wall install step done (see armed field above)" || echo "❌ FAILED install wall"
Root, lock, backup — the three invariants every settings-writing block here shares.
ROOT is the canonical recipe (CLAUDE_PROJECT_DIR → git rev-parse --show-toplevel → upward
walk for .git/.claude → abort). An installer that cannot name its root ABORTS non-zero and
says what it looked for — it never writes to a guessed root, and it never uses raw $PWD, which
drifts to whatever subdirectory the session wandered into. The settings.local.json.lock
directory is an O_EXCL mutex (stale-broken after 30 s) so two setup skills running in parallel
cannot lose each other's edit; the file is re-read INSIDE the lock. settings.local.json.bak is
written before every rename. A read that is not ENOENT, or content that is not a JSON object,
ABORTS before anything is staged — a malformed settings file is never "the file is empty".
After the block:
- If
armed:true → tell the user the exit command verbatim, with the real absolute path: node <ABS project root>/.claude/brewtools/manager/manager-state.mjs set hard=false — or just /brewtools:manager-setup disable, which runs exactly that.
- If
armed:false → tell the user plainly: installed but NOT armed — the guard is in place but state.hard=false; run /brewtools:manager-setup enable (or answer "Yes, arm it now" next time) to arm it.
- If
newlyRegistered:true → tell the user verbatim: Hook installed in .claude/settings.local.json — run /reload (or restart the session) for the wall to take effect.
- If
newlyRegistered:false → the entry already existed; a reload is only needed if armed:true just flipped a previously-disarmed state.
- Report
todoTools in one line: enabled → say TaskCreate/TaskUpdate/TaskGet/TaskList enabled via env.CLAUDE_CODE_ENABLE_TODO_TOOLS in .claude/settings.local.json; skipped → print the reason verbatim and move on.
The command in the registered entry uses an ABSOLUTE path to the copied guard and a # brewtools-manager-guard tag comment so uninstall can find it. Scope is always project — there is no global wall, never pass 'global'.
upgrade (re-emit the guard from the current plugin version — arm state kept, provenance restamped)
upgrade replays the install against the CURRENT plugin version so a claude plugin update finally reaches an already-installed project: it re-copies hardmode-guard.mjs and manager-state.mjs, re-registers the entry if it went missing, and — in the same read-merge-atomic-write of settings.local.json — sets env.CLAUDE_CODE_ENABLE_TODO_TOOLS = "1" on Claude Code >= 2.1.233. A project installed before the off-switch CLI existed has no project copy of manager-state.mjs, and one installed before the task-tool gate has no env key; upgrade is what backfills both, so run it once after updating brewtools. It asks nothing.
It restamps state.json, and ONLY the metadata trio. setup-status row 8 reads the
top-level "version" of .claude/brewtools/manager/state.json as the headline; the guard's
brewcode-meta: line is SECOND precedence, consulted only when that key is absent. So an
upgrade that re-copied the guard but left state.json alone reported the old version forever
and status printed stale after every upgrade — the staleness could never be cleared.
The fix is the docsync-setup shape (brewdoc/skills/docsync-setup/SKILL.md mode upgrade):
call writeState('project', {}, cwd) — an EMPTY partial. writeState merges
{...existing, ...partial} and then stamps version / generated_by / last_updated, so with
nothing in the partial it rewrites the trio and nothing else. hard and level are
preserved byte-for-byte out of the existing file: a disarmed wall stays disarmed, an armed one
stays armed, a customized level survives. That is what stateUntouched used to promise and it
still holds for the ARM state — the block now reports armStatePreserved + stateRestamped so
the two are not conflated.
It ABORTS when the project has no wall installed. upgrade must never be a back door that arms a wall the user never asked for — an uninstalled project is told to run install.
EXECUTE using Bash tool:
SD="${CLAUDE_SKILL_DIR}"
if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi
[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; }
test -f "$BT_ROOT/hooks/hardmode-guard.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }
ROOT=$(if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then printf %s "$CLAUDE_PROJECT_DIR"; elif r=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$r" ]; then printf %s "$r"; else d=$PWD; while [ "$d" != "/" ]; do if [ -d "$d/.git" ] || [ -d "$d/.claude" ]; then printf %s "$d"; break; fi; d=$(dirname "$d"); done; fi)
[ -n "$ROOT" ] || { echo "❌ cannot resolve project root — looked for CLAUDE_PROJECT_DIR, git toplevel, then .git/.claude above $PWD; nothing written"; exit 1; }
CCVER=$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)
node --input-type=module -e "
import fs from 'node:fs'; import path from 'node:path';
import {writeState, resolveStatePath} from '${BT_ROOT}/hooks/lib/manager-state.mjs';
const cwd = '${ROOT}';
const src = '${BT_ROOT}/hooks/hardmode-guard.mjs';
const dir = path.join(cwd, '.claude', 'brewtools', 'manager');
const guard = path.join(dir, 'hardmode-guard.mjs');
const settings = path.join(cwd, '.claude', 'settings.local.json');
const TAG = 'brewtools-manager-guard';
const has = m => Array.isArray(m.hooks) && m.hooks.some(h => typeof h.command==='string' && (h.command.includes(TAG) || h.command.includes('hardmode-guard.mjs')));
// Task graph: CC 2.1.233+ gates TaskCreate/Update/Get/List off unless env.CLAUDE_CODE_ENABLE_TODO_TOOLS
// is set. Numeric compare, never string. Below 2.1.233 the key is a no-op, so skip the write.
const ccVer = '${CCVER}';
const geVersion = (v, t) => { const a = String(v).split('.').map(n => parseInt(n, 10)); return a.length === 3 && !a.some(Number.isNaN) && (a[0] - t[0] || a[1] - t[1] || a[2] - t[2]) >= 0; };
const todoToolsGated = geVersion(ccVer, [2,1,233]);
const todoTools = todoToolsGated ? 'enabled (CC ' + ccVer + ')'
: ccVer ? 'skipped — CC ' + ccVer + ' predates the 2.1.233 gate, task tools are on by default'
: 'skipped — could not read the Claude Code version; on 2.1.233+ set env.CLAUDE_CODE_ENABLE_TODO_TOOLS=1 by hand';
const lock = settings + '.lock';
fs.mkdirSync(path.dirname(settings), {recursive:true});
let held = false;
for (let i = 0; i < 50 && !held; i++) {
try { fs.mkdirSync(lock); held = true; }
catch {
try { if (Date.now() - fs.statSync(lock).mtimeMs > 30000) { fs.rmSync(lock, {recursive:true, force:true}); continue; } } catch {}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
}
}
if (!held) { console.error('ABORT: ' + lock + ' is held by another setup skill — retry in a moment; nothing was written'); process.exit(1); }
let newlyRegistered = false;
try {
let cfg = {};
try { cfg = JSON.parse(fs.readFileSync(settings,'utf8')); }
catch (e) { if (e.code !== 'ENOENT') { console.error('ABORT: ' + settings + ' unreadable or invalid JSON (' + e.message + ') — fix it by hand; nothing was written'); process.exit(1); } }
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) { console.error('ABORT: ' + settings + ' is not a JSON object — fix it by hand; nothing was written'); process.exit(1); }
const arr = (cfg.hooks && Array.isArray(cfg.hooks.PreToolUse)) ? cfg.hooks.PreToolUse : [];
if (!arr.some(has) && !fs.existsSync(guard)) { console.error('ABORT: the wall is not installed in this project — run install instead'); process.exit(1); }
fs.mkdirSync(dir, {recursive:true});
fs.copyFileSync(src, guard);
fs.copyFileSync('${BT_ROOT}/hooks/lib/manager-state.mjs', path.join(dir, 'manager-state.mjs'));
if (!arr.some(has)) { arr.push({ matcher:'*', hooks:[{ type:'command', command:\`node \"\${guard}\" # \${TAG}\`, timeout:5 }] }); newlyRegistered = true; }
cfg.hooks = (cfg.hooks && typeof cfg.hooks==='object') ? cfg.hooks : {};
cfg.hooks.PreToolUse = arr;
if (todoToolsGated) { cfg.env = (cfg.env && typeof cfg.env==='object' && !Array.isArray(cfg.env)) ? cfg.env : {}; cfg.env.CLAUDE_CODE_ENABLE_TODO_TOOLS = '1'; }
if (fs.existsSync(settings)) fs.copyFileSync(settings, settings + '.bak');
const tmp = settings + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
fs.renameSync(tmp, settings);
} finally { fs.rmSync(lock, {recursive:true, force:true}); }
// Restamp the metadata trio ONLY — empty partial, so hard/level/mode and every
// unknown key merge through from the existing file untouched.
let before = null;
try { before = JSON.parse(fs.readFileSync(resolveStatePath('project', cwd),'utf8')); } catch {}
const w = await writeState('project', {}, cwd);
const armStatePreserved = !before || (w.state.hard === before.hard && w.state.level === before.level);
console.log(JSON.stringify({guardReplaced:true, guard, newlyRegistered, todoTools,
stateRestamped:{version:w.state.version, generated_by:w.state.generated_by, last_updated:w.state.last_updated},
hard:w.state.hard, level:w.state.level, armStatePreserved}));
" && echo "✅ wall upgraded (arm state preserved, state.json restamped)" || echo "❌ FAILED upgrade"
Surface the /reload note only when newlyRegistered:true. Report todoTools in one line the same way install does — an old project that predates the key gets it backfilled here.
enable (ARM an installed wall — state flip only)
Gated by P1. This block only runs if the P1 arm-confirmation gate resolved ARM=true (explicit
wording in the user's prompt, or an AskUserQuestion answered "Yes, arm it now"). If P1 resolved
ARM=false, do NOT run this block at all — report plainly that arming was declined and nothing
changed, and stop.
enable flips state.hard=true and nothing else. If the wall was never installed there is no guard to arm, so the block reports notInstalled instead of writing a state
…(truncated)
1---2name: manager-setup3description: Manager mode: installs a hard delegation wall into this project — status, install, upgrade, enable, disable, uninstall, purge, level, edit — and explains/customizes codewords ++m, ++a, ++rr, ++r. Triggers: manager, менеджер, hard mode, хард режим, delegate.4---5
6# Manager
7
8> Manager mode has **TWO independent layers**. Keep them straight:
9>
10> 1. **SOFT codewords (`++m` / `++a` / `++rr` / `++r`) — autonomous, hook-driven, ALWAYS fire.** A `UserPromptSubmit` hook (`hooks/manager-prompt.mjs`) watches every prompt; when it sees a codeword it injects the matching block as `additionalContext` for that one turn. This is NOT enabled/disabled by this skill — it works regardless of skill state. The skill only **explains** it (`status`) and **customizes its TEXT** (`edit`/`purge`).
11> Detection (longest-prefix first within the review group):
12> - `++m` → Manager mode. PLAN-AWARE: when the session is in plan mode (`permission_mode === 'plan'`) it injects the `planmode` block (full + plan addon — writes the task graph, uses the tasks tool); otherwise the plain `full` delegate-everything block. There is NO separate `++mp` codeword.
13> - `++a` → Architecture-first directive (`architect`). Injects `[DIRECTIVE: ARCHITECTURE-FIRST]` before implementation — delegate an architecture pass that fits the project's existing architecture, patterns and rules; robust, scalable, and SIMPLE (no over-engineering); find the closest well-built counterpart in the repo and take its principles (additive to conventions/rules, not a replacement), clean seams. Independent group — combines with `++m` and the review group. Mode-agnostic: same block in plan and normal mode (in plan mode it is written into the plan).
14> - `++rr` → Regression Review discipline (`review-regression`) — after each significant phase: no regression + project standard + correctness; two-phase review→double-check→fix; final cross-review at task end. Tested before `++r`.
15> - `++r` → Review discipline (`review-double`) — two-phase multi-agent review→double-check→fix after each significant change; codeword-only (no ambient/wall injection).
16> - When the HARD wall is ON, the Manager (full) block is ALSO auto-injected on EVERY turn — no codeword needed. Codewords and wall injection are independent.
17> 2. **HARD wall — opt-in, this skill only, PER-PROJECT, INSTALLED-INTO-THE-PROJECT, persistent.** The wall is **NOT** a plugin hook. `install` does two things: it **installs** a self-contained `PreToolUse` guard into THIS project (copies the guard file + idempotently registers it in `<cwd>/.claude/settings.local.json`) and, **only after the user has explicitly confirmed arming** (P1 arm-confirmation gate — an `AskUserQuestion` answered "Yes, arm it now", or explicit wording like "enable the hard wall"/"включи хард уолл" already in the user's own prompt; the bare verb `install`/`установи` and any autonomy phrasing like "decide everything yourself"/"автономно" NEVER count as confirmation), **arms** it by flipping `state.hard=true`. Declining still installs the guard, leaving `state.hard=false`. The registered guard then **physically denies** mutating tools (Write/Edit/Bash/WebFetch/...) in the **main session**, leaving only delegate/read/track. Subagents stay fully free (`agent_id` linchpin). `enable` re-arms an already-installed wall — same confirmation gate, decline aborts with nothing changed; `disable` only flips `state.hard=false` — registration stays, the guard no-ops. `uninstall` removes the registration and the copied guard; `purge` also deletes the state file and the prompt overrides. The wall lives in project state + project settings, defaults OFF, persists until `disable`/`uninstall`. There is **no codeword** for the wall.
18>
19> The two layers are orthogonal: the wall enforces delegation by removing hands; the codewords/prompt-text shape the Manager mindset. Either can be used alone.
20>
21> **INSTALL-ONCE + STATE-GATE (the safety crux):** the guard is *registered once* in `settings.local.json` (a personal, gitignored file) but is *gated at runtime* by project `state.json {hard}`. Registration is the persistent plumbing; `state.hard` is the live kill-switch. This split exists because **while the wall is armed it DENIES Edit/Bash on arbitrary files** — so `disable` must NOT touch `settings.local.json` (that edit would be blocked). Instead `disable` flips `state.json` with the ONE Bash shape the guard self-exempts, so the state flip always succeeds even at `level strict`. Conclusion: `state.json` is the runtime kill-switch; registration is harmless inert plumbing left in place.
22>
23> **THE EXEMPT COMMAND (memorize this shape — nothing else gets through an armed wall):**
24> ```
25> node <ABS project root>/.claude/brewtools/manager/manager-state.mjs set hard=false
26> ```
27> The guard exempts it only when ALL of these hold: the command starts with `node `, the FIRST argument after `node` **resolves (realpath) to the helper this project actually installed** — `<root>/.claude/brewtools/manager/manager-state.mjs`, or the plugin's own `hooks/lib/manager-state.mjs` next to the guard — there is no shell operator outside quotes, no `$` expansion, no eval flag (`-e`/`--eval`/`-p`/`--print`/`--input-type`/`--require`/`--import`/`--loader`), and the remaining arguments are the helper's own CLI (`get` | `set hard=<true|false> level=<strict|balanced> mcpAllow=<mcp__srv__tool[,...]|> [--cwd DIR]`). **No `BT_ROOT=` prelude, no `&& echo`, no `|| echo`, no `test -f` — every one of those is a shell operator and turns the exemption OFF.** A file merely *named* `manager-state.mjs` elsewhere on disk is NOT exempt: the anchor is the absolute installed path, not the filename or a path suffix. `install`/`upgrade` copy the helper into the project precisely so this command needs no path resolution.
28
29## What the guard actually enforces (read this before you need it)
30
31| Property | Behaviour |
32|---|---|
33| Who is walled | The MAIN session only. Subagents are free BY DESIGN — the discriminator is `agent_id` in the PreToolUse payload, present only for subagent calls. A `claude --agent <name>` main session carries `agent_type` without `agent_id` and IS walled. |
34| Project root | Resolved as `CLAUDE_PROJECT_DIR` → upward walk for `.git`/`.claude` → hook `cwd`, plus the guard's own installed directory. State is found from ANY nested working directory; a deep `cwd` no longer silently disables the wall. |
35| Fail-closed | An unparseable PreToolUse payload, an internal guard error, or an installed manager directory whose `state.json` is missing/corrupt all DENY the main session (at `strict` semantics) instead of passing through. Subagents still pass. |
36| `balanced` Bash | A strict allowlist of exact binaries (`ls cat pwd which head tail wc grep rg date whoami basename dirname realpath test [ jq echo find git gh node`) plus per-binary flag vetting: `rg --pre/--pre-glob/--search-zip`, `find -exec/-ok/-delete/-fprint*`, `git -c/--exec-path/--upload-pack/--ext-diff`, and `node` anything other than `--check` are DENIED. `env` is not on the list at all — it is a universal exec wrapper. Any `>`/`<` redirection, `$(...)` or backtick anywhere denies the whole command. |
37| `strict` Bash | Everything above is denied too; only the exempt state CLI runs. |
38| MCP | Classified on the tool segment after the second `__`, so a server named `search` cannot launder `mcp__search__destroy_all`. Default-deny per token: an unrecognised verb **or noun** → denied, and an ambiguous verb (`query`/`resolve`) reads ONLY inside a docs/reference name (`query-docs`, `resolve-library-id`), so `mcp__sqlite__query`, `mcp__sqlite__query_table` and `mcp__linear__resolve_issue` are all denied — those write on a DB server or tracker. `mcpAllow` is the escape hatch. |
39| `mcpAllow` | Optional state key, the escape hatch for a false MCP denial. Entries are an exact scoped name `mcp__server__tool` or a whole-server prefix `mcp__server__*`; consulted BEFORE the classifier and at **`balanced` ONLY** — `strict` denies all MCP, allowlisted or not. A malformed value allows nothing and never breaks state. |
40
41**Recovery, in order of preference.**
421. `node <ABS root>/.claude/brewtools/manager/manager-state.mjs set hard=false` — works at every level, including when `state.json` is corrupt (it rewrites the file).
432. A genuinely read-only MCP tool denied by the classifier — allowlist it (`balanced` only), same self-exempt command shape, quote the value so the shell keeps `*`:
44 `node <ABS root>/.claude/brewtools/manager/manager-state.mjs set 'mcpAllow=mcp__semble_code__*,mcp__github__get_file'` — the list is replaced wholesale, one invalid entry writes nothing (exit 2), and `set 'mcpAllow='` clears it.
453. Delegate: `Task` is always allowed and subagents are unwalled, so a subagent can run `/brewtools:manager-setup upgrade` or repair state for you.
464. Two residual cases need action OUTSIDE the session, and there is no in-session workaround — do not go hunting for one:
47 - Claude Code changes the PreToolUse payload shape so the guard cannot parse it. Every main-session mutation is then denied. Fix: quit and delete the `brewtools-manager-guard` entry from `.claude/settings.local.json` in an editor.
48 - Deleting the whole `.claude/brewtools/` tree disarms the wall (no manager directory = never installed). That is the documented consequence of a manual `rm -rf`, not a way to disable the wall — use `disable` or `uninstall`, which keep settings and files consistent.
49
50## Prompt contract
51
52Position 1 of `$ARGUMENTS` is a **free-form prompt** (RU/EN) — modes and flags are optional and may
53follow in any order. Nobody types keys: resolve the action + scope FROM the prompt via
54`references/intent-routing.md` (P0 table below is the same routing, EN/RU keyword split).
55
561. Strip flags. An explicit action token anywhere wins outright, no scoring.
572. Else score actions by distinct whole-word keyword hits (P0 table). Highest unique score wins.
58 Tie with a destructive action (`purge`) -> `AskUserQuestion`; tie with `status` -> `status`;
59 tie of two mutating actions -> the keyword appearing first; all zero -> `status`.
603. Empty arguments -> `status`; ask ONE scoping `AskUserQuestion` only when the answer changes
61 what gets written or armed. `status` asks nothing.
624. Outcome-changing ambiguity (incl. `hard-one-shot` vs `manager-run`, enable vs disable) -> ONE
63 `AskUserQuestion` (max 4 questions) BEFORE any work — this is P1.
645. Prose that is not an action/id/path is still input: extract the task from `<task> в хард
65 режиме` / `<task> от роли менеджера` rather than treating the first word as a positional id.
66
67Then print this block ONCE, after P1 and before the first action (P2). A read-only `status`
68prints it immediately before its report:
69
70```
71PLAN — brewtools:manager-setup
72INPUT: <arguments verbatim, or "(empty)">
73MODE: <resolved action> — <explicit | matched keyword: X | default>
74SCOPE: <resolved paths / level / task> — LAYER: <codewords (soft, always-on, hook-driven) |
75 HARD wall (opt-in, this project, PreToolUse guard)> — name which layer this action touches
76DO: <2-5 imperative bullets>
77RESULT: <what the user ends up holding>
78```
79
80Labels are literal; values follow the conversation language. `install`/`upgrade`/`enable`/
81`disable`/`uninstall`/`purge`/`level` touch the HARD-wall layer; `edit` touches the codewords
82layer (prompt text only); `hard-one-shot` touches BOTH (arms/disarms the wall AND runs the task
83under the codewords contract); `manager-run`/`inline-run` touch only the codewords layer.
84
85<instructions>
86
87## Robustness Rules
88
89| Rule | Applies |
90|------|---------|
91| Every Bash call ends with `&& echo "✅ ..." \|\| echo "❌ FAILED ..."` | ALL **except** the bare exempt state-write command (see the crux box) — appending `&& echo` there makes the armed wall deny it |
92| The HARD wall (`state.hard`) is **PROJECT scope ONLY** — there is no global wall. Always `writeState('project', ...)` for `hard`/`level` | install/enable/disable/level/hard-one-shot |
93| The wall is **installed INTO the project**, not shipped as a plugin hook. `install`/`upgrade` copy the guard + register it in `<cwd>/.claude/settings.local.json`; `enable`/`disable` flip state only; `uninstall`/`purge` deregister | install/upgrade/enable/disable/uninstall/purge |
94| All `settings.local.json` mutations go through a **node Bash block** (read-merge-atomic-write), NEVER the Edit tool — the Edit tool may be blocked by an armed wall, and we must not depend on it | install/upgrade/uninstall/purge |
95| State writes go through `writeState(scope, partial, cwd)` (atomic: lockfile + tmp + rename) — never write `state.json` by hand | P2 |
96| State reads go through `resolveState(cwd)`; prompts via `resolvePrompt(mode, cwd, root)` / `resolvePromptPath(scope, mode, cwd)` | P2, status |
97| Never reimplement resolution logic — always call the helpers | ALL |
98| GLOBAL prompt-override paths (`~/.claude/manager/prompts/*`) are PROTECTED for Write/Edit — write ONLY via the Node helper through Bash. Project prompt overrides are plain writes (still prefer helper) | edit/purge |
99
100### Scope, said once so it is never confused
101
102| Thing | Scope | Files |
103|-------|-------|-------|
104| **Wall state** `{hard, level}` + optional `mcpAllow` (runtime kill-switch) | **PROJECT ONLY** | `<cwd>/.claude/brewtools/manager/state.json` |
105| **Wall registration** (persistent plumbing) | **PROJECT ONLY** | `<cwd>/.claude/settings.local.json` (PreToolUse `*` entry) + copied guard `<cwd>/.claude/brewtools/manager/hardmode-guard.mjs` |
106| Soft default `mode` field (informational) | project state | same `state.json` |
107| **Prompt-text overrides** (`edit`/`purge`) | project **or** global (separate files) | project: `<cwd>/.claude/brewtools/manager/prompts/<mode>.md` · global: `~/.claude/manager/prompts/<mode>.md` |
108
109> "Wall scope" is fixed (project). "Prompt-text override scope" is a different, independent axis that `edit`/`purge` may target globally. Do not let `--scope global` leak onto the wall — it has no meaning there.
110
111### BT_ROOT Resolver
112
113The plugin root is resolved from the skill's OWN directory (the `CLAUDE_SKILL_DIR` prompt substitution), never from `CLAUDE_PLUGIN_ROOT` -- that env var is not exported to a skill's Bash tool. Every Bash block resolves `BT_ROOT` this way (no hardcoded version):
114
115```bash
116SD="${CLAUDE_SKILL_DIR}"
117if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi
118[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; }
119test -f "$BT_ROOT/hooks/lib/manager-state.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }
120```
121
122Paths (use `$BT_ROOT` literally in Bash):
123- State helper: `$BT_ROOT/hooks/lib/manager-state.mjs` — exports `resolveState`, `writeState`, `resolveStatePath`; also a CLI: `node <path>/manager-state.mjs get|set hard=<true|false> level=<strict|balanced> mcpAllow=<mcp__srv__tool[,...]|> [--cwd DIR]`
124- Prompt helper: `$BT_ROOT/hooks/lib/manager-prompts.mjs` — exports `resolvePrompt`, `resolvePromptPath`
125- **Guard source (shipped, self-contained, NOT in plugin `hooks.json`):** `$BT_ROOT/hooks/hardmode-guard.mjs` — `install`/`upgrade` copy this into the project
126- Plugin default blocks: `$BT_ROOT/skills/manager-setup/references/<mode>.md` (`full.md`, `planmode.md`)
127- Wall policy + canonical status text: `$BT_ROOT/skills/manager-setup/references/hard.md` — **Read it for the install model, status explainer and the allowlist details.**
128
129Project install targets (resolved from `process.cwd()`):
130- Copied guard: `<cwd>/.claude/brewtools/manager/hardmode-guard.mjs`
131- Copied state helper: `<cwd>/.claude/brewtools/manager/manager-state.mjs` — the off-switch CLI. Copied so `disable`/`level` are a fixed path needing no `BT_ROOT` resolution (resolution needs shell operators, which the armed wall denies)
132- Registration: `<cwd>/.claude/settings.local.json` — a `PreToolUse` matcher `"*"` entry whose command runs `node <ABS path to copied guard>`. Tagged with marker `brewtools-manager-guard` so `uninstall`/`purge` can find it.
133
134### Resolution chains (must match helpers exactly)
135
136| What | project | → global | → default |
137|------|---------|----------|-----------|
138| State `mode` (informational) | `<cwd>/.claude/brewtools/manager/state.json` | `~/.claude/manager/state.json` | `mode:'full'` |
139| Wall flags `{hard, level, mcpAllow}` | `<cwd>/.claude/brewtools/manager/state.json` | (no global — PROJECT-ONLY) | `{hard:false, level:'balanced', mcpAllow:[]}` |
140| Prompt text `<mode>` | `<cwd>/.claude/brewtools/manager/prompts/<mode>.md` | `~/.claude/manager/prompts/<mode>.md` | `$BT_ROOT/skills/manager-setup/references/<mode>.md` |
141
142> The wall flags (`hard`/`level`) are resolved **PROJECT-ONLY in code** — the global `state.json` does NOT enable the wall. The skill writes them to **project** scope only. (The informational `mode` field may still resolve from global; `hard`/`level` do not.)
143
144---
145
146## P0: Resolve Intent
147
148Parse `$ARGUMENTS` (or the user's NL prompt, RU+EN) into `{ action, scope, mode, level, task }` using `references/intent-routing.md` — **Read and follow it**.
149
150Actions, canonical order: `status`, `install`, `upgrade`, `enable`, `disable`, `uninstall`, `purge`, plus the extras `level <strict|balanced>`, `edit`, and the run actions `hard-one-shot`, `manager-run`, `inline-run`.
151
152| Action | EN keywords | RU keywords | Mutates? | Resolves |
153|--------|-------------|--------------|----------|----------|
154| `status` | *(empty)*, `status` | `статус`, `что сейчас` | no | the main explainer, and the default |
155| `install` | `install` (no task) | `установи`, `поставь стену` | yes | INSTALL + ARM the HARD wall for this project |
156| `upgrade` | `upgrade` | `обнови`, `перекопируй гард` | yes | re-copy the guard + re-register from the CURRENT plugin version; `hard`/`level` preserved |
157| `enable` | `enable`, `on`, `arm` (no task) | `вкл`, `включи` | yes | ARM an installed wall (state flip only). NOT registered yet → treat as `install` |
158| `disable` | `disable`, `off`, `disarm` | `выкл`, `выключи`, `стена выкл`, `стену выключи` | yes | DISARM the wall (state only; registration stays) |
159| `uninstall` | `uninstall`, `teardown`, `remove hook` | `снеси стену`, `удали хук`, `деинсталлируй` | yes | DEREGISTER the wall from `settings.local.json` + delete the copied guard (auto-disarms first). State and prompt overrides are KEPT |
160| `purge` | `purge` | `вычисти`, `снеси всё`, `верни дефолт`, `сброс` | yes, destructive | uninstall + delete `state.json` AND the prompt-text override(s) |
161| `level strict` | `level strict` | `режим строгий` | yes | wall strictness = strict |
162| `level balanced` | `level balanced` | `режим сбалансированный` | yes | wall strictness = balanced |
163| `edit` | `edit` | `поправь промт` | yes (prompt-text only) | prompt-text only (Manager prompt text) |
164| `hard-one-shot` | `<task> in hard mode` | `<task> в хард режиме` | yes (arms, auto-reverts) | has a REAL task + hard marker |
165| `manager-run` | `<task> as manager` | `<task> от роли менеджера` | no (wall untouched) | run task in manager role, wall untouched |
166| `inline-run` | bare task, no control verb, no marker | — | no (wall untouched) | gentle default for a bare task |
167
168> `on` / `off` / `reset` / `setup` / `remove` are REMOVED as command words. `on` and `off` survive only as free-text synonyms routed to `enable` / `disable` above; `reset` routes to `purge`. Never print them as commands.
169
170> **Arming is never automatic.** `install`, `enable`, and `hard-one-shot` are the only actions that can
171> write `state.hard=true`. Resolving the action (even from an explicit keyword like `install`, or
172> autonomy phrasing like "autonomous, decide everything yourself"/"автономно выбирай сам") is NOT
173> confirmation to arm — that confirmation is a separate, mandatory P1 gate. See P1 below.
174
175Prompt-text override scope (ONLY for `edit`/`purge`): default = `project`. `--scope global` OR `глобально` / `globally` → `global`. This scope does NOT apply to `install`/`upgrade`/`enable`/`disable`/`uninstall`/`level` (those are project-only).
176
177---
178
179## P1: Echo + Disambiguate
180
181Print ONE line stating the resolved intent, e.g.:
182```
183Understood: install + arm the hard wall (project), level=balanced
184```
185If the action is ambiguous or signals conflict (e.g. enable + disable, a task that might be `hard-one-shot` vs `manager-run`, control implied but no verb) → `AskUserQuestion` with the candidate actions as options. Otherwise proceed.
186
187> Distinguish carefully: `hard-one-shot` (task + "в хард режиме"/"in hard mode") flips the wall and auto-reverts; `manager-run` (task + "от роли менеджера"/"as manager") never touches the wall, discipline by prompt only. If both/neither marker is present and a task exists, ask.
188
189**Arm-confirmation gate (unconditional — not only on ambiguity).** `install`, `enable`, and
190`hard-one-shot` are the only actions that can write `state.hard=true`. Before P2 runs any of them,
191check whether the user's OWN prompt already carries EXPLICIT confirming wording: the words "hard
192wall" / "хард уолл" / "стену" TOGETHER with an enable/arm verb ("enable"/"arm"/"включи"/"заarmи"),
193e.g. "enable the hard wall", "arm the wall", "включи Hard Wall", "включи хард уолл", "заarmи стену".
194The bare skill verb alone (`install`/`установи`/`enable`/`включи` with no "wall" wording) does
195**not** count, and no autonomy-permission phrasing ever counts ("autonomous, decide everything
196yourself", "автономно", "выбирай сам" — these NEVER satisfy the gate). If explicit wording is
197present, treat the arm as already confirmed and skip the question. Otherwise ask exactly ONE
198`AskUserQuestion` before P2:
199- `install`: "Arm the HARD wall in this project now? It will block Write/Edit/Bash in the MAIN
200 session (subagents stay free) until disabled." — options `Yes, arm it now` / `No, just install
201 (stay disarmed)`.
202- `enable` / `hard-one-shot`: the same question, options `Yes, arm it now` / `No, cancel`.
203
204Record the outcome as `ARM` (`true`/`false`) for P2:
205- `install` proceeds either way — it always copies the guard and registers the hook; `ARM` only
206 decides whether `state.hard` is written `true` or `false`. `ARM=false` → report "installed but
207 NOT armed — run `enable` (or confirm next time) to arm it."
208- `enable` / `hard-one-shot`: `ARM=false` aborts the whole action before running anything — do not
209 touch `state.hard`, report plainly that nothing changed.
210
211---
212
213## P2: Execute
214
215Print the `## Prompt contract` PLAN block first — INPUT/MODE from P0, SCOPE naming which layer
216(codewords vs HARD wall, or both for `hard-one-shot`) — before running the mapped section below.
217`status` prints the same block immediately before its report instead of before a mutation.
218
219> Sections below map 1:1 to `action`: `install`, `upgrade`, `enable`, `disable`, `uninstall`, `purge`, `level`, `status`, `edit`, and the three run actions.
220
221### install (INSTALL + ARM the HARD wall — project only)
222
223`install` is a five-step sequence: (1) set arm state per the P1 confirm gate (`ARM`), (2) copy the guard into the project, (3) idempotently register it in `settings.local.json`, (4) turn the task-graph tools on in that same file, (5) report whether a `/reload` is needed. All five run in ONE node Bash block so the registration is atomic and self-contained. **`ARM` (`true`/`false`) is resolved by the P1 arm-confirmation gate — substitute it into the block below before running, same as `LEVEL` in the `level` action.** The block:
224- sets `state.hard = (ARM === 'true')` via `writeState('project', {hard:arm})` — arms only when P1 confirmed,
225- copies `$BT_ROOT/hooks/hardmode-guard.mjs` → `<cwd>/.claude/brewtools/manager/hardmode-guard.mjs` **and** `$BT_ROOT/hooks/lib/manager-state.mjs` → `<cwd>/.claude/brewtools/manager/manager-state.mjs` (both overwritten on EVERY `install`, so plugin updates propagate; the second one is the off-switch CLI),
226- read-merge-atomic-writes `<cwd>/.claude/settings.local.json`, adding a `PreToolUse` matcher `"*"` entry that runs `node <ABS copied-guard path>` tagged `brewtools-manager-guard`, but ONLY if no entry already points at the manager guard (idempotent — running twice = ONE entry),
227- in that SAME merge sets `env.CLAUDE_CODE_ENABLE_TODO_TOOLS = "1"` when Claude Code is >= 2.1.233 — creating the `env` object if absent, preserving every other key. This is unconditional and never asks: from 2.1.233 `TaskCreate`/`TaskUpdate`/`TaskGet`/`TaskList` are gated OFF by default, and the manager framework has no task graph without them. Below 2.1.233 the var does nothing and the tools are on anyway, so the write is skipped and the block says so,
228- prints `newlyRegistered` and `todoTools` so you know whether to surface the `/reload` note and what happened to the task tools.
229
230**EXECUTE** using Bash tool:
231```bash
232SD="${CLAUDE_SKILL_DIR}"
233if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi
234[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; }
235test -f "$BT_ROOT/hooks/hardmode-guard.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }
236ROOT=$(if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then printf %s "$CLAUDE_PROJECT_DIR"; elif r=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$r" ]; then printf %s "$r"; else d=$PWD; while [ "$d" != "/" ]; do if [ -d "$d/.git" ] || [ -d "$d/.claude" ]; then printf %s "$d"; break; fi; d=$(dirname "$d"); done; fi)
237[ -n "$ROOT" ] || { echo "❌ cannot resolve project root — looked for CLAUDE_PROJECT_DIR, git toplevel, then .git/.claude above $PWD; nothing written"; exit 1; }
238CCVER=$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)
239ARM=<true|false>
240node --input-type=module -e "
241import {writeState} from '${BT_ROOT}/hooks/lib/manager-state.mjs';
242import fs from 'node:fs'; import path from 'node:path';
243const cwd = '${ROOT}';
244const arm = '${ARM}' === 'true';
245const src = '${BT_ROOT}/hooks/hardmode-guard.mjs';
246const dir = path.join(cwd, '.claude', 'brewtools', 'manager');
247const guard = path.join(dir, 'hardmode-guard.mjs');
248const helper = path.join(dir, 'manager-state.mjs');
249const settings = path.join(cwd, '.claude', 'settings.local.json');
250const TAG = 'brewtools-manager-guard';
251// Task graph: CC 2.1.233+ gates TaskCreate/Update/Get/List off unless env.CLAUDE_CODE_ENABLE_TODO_TOOLS
252// is set. Numeric compare, never string. Below 2.1.233 the key is a no-op, so skip the write.
253const ccVer = '${CCVER}';
254const geVersion = (v, t) => { const a = String(v).split('.').map(n => parseInt(n, 10)); return a.length === 3 && !a.some(Number.isNaN) && (a[0] - t[0] || a[1] - t[1] || a[2] - t[2]) >= 0; };
255const todoToolsGated = geVersion(ccVer, [2,1,233]);
256const todoTools = todoToolsGated ? 'enabled (CC ' + ccVer + ')'
257 : ccVer ? 'skipped — CC ' + ccVer + ' predates the 2.1.233 gate, task tools are on by default'
258 : 'skipped — could not read the Claude Code version; on 2.1.233+ set env.CLAUDE_CODE_ENABLE_TODO_TOOLS=1 by hand';
259// 1. arm only if P1 confirmed
260await writeState('project', {hard:arm}, cwd);
261// 2. copy guard + off-switch CLI (overwrite each install)
262fs.mkdirSync(dir, {recursive:true});
263fs.copyFileSync(src, guard);
264fs.copyFileSync('${BT_ROOT}/hooks/lib/manager-state.mjs', helper);
265// 3. idempotent register, under the settings lock
266const lock = settings + '.lock';
267fs.mkdirSync(path.dirname(settings), {recursive:true});
268let held = false;
269for (let i = 0; i < 50 && !held; i++) {
270 try { fs.mkdirSync(lock); held = true; }
271 catch {
272 try { if (Date.now() - fs.statSync(lock).mtimeMs > 30000) { fs.rmSync(lock, {recursive:true, force:true}); continue; } } catch {}
273 Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
274 }
275}
276if (!held) { console.error('ABORT: ' + lock + ' is held by another setup skill — retry in a moment; nothing was written'); process.exit(1); }
277let newlyRegistered = false;
278try {
279 let cfg = {};
280 try { cfg = JSON.parse(fs.readFileSync(settings,'utf8')); }
281 catch (e) { if (e.code !== 'ENOENT') { console.error('ABORT: ' + settings + ' unreadable or invalid JSON (' + e.message + ') — fix it by hand; nothing was written'); process.exit(1); } }
282 if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) { console.error('ABORT: ' + settings + ' is not a JSON object — fix it by hand; nothing was written'); process.exit(1); }
283 cfg.hooks = (cfg.hooks && typeof cfg.hooks==='object') ? cfg.hooks : {};
284 const arr = Array.isArray(cfg.hooks.PreToolUse) ? cfg.hooks.PreToolUse : [];
285 const has = m => Array.isArray(m.hooks) && m.hooks.some(h => typeof h.command==='string' && (h.command.includes(TAG) || h.command.includes('hardmode-guard.mjs')));
286 if (!arr.some(has)) {
287 arr.push({ matcher:'*', hooks:[{ type:'command', command:\`node \"\${guard}\" # \${TAG}\`, timeout:5 }] });
288 newlyRegistered = true;
289 }
290 cfg.hooks.PreToolUse = arr;
291 // 4. task graph on, same merge — idempotent, one key, every other setting preserved.
292 if (todoToolsGated) { cfg.env = (cfg.env && typeof cfg.env==='object' && !Array.isArray(cfg.env)) ? cfg.env : {}; cfg.env.CLAUDE_CODE_ENABLE_TODO_TOOLS = '1'; }
293 if (fs.existsSync(settings)) fs.copyFileSync(settings, settings + '.bak');
294 const tmp = settings + '.tmp';
295 fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
296 fs.renameSync(tmp, settings);
297} finally { fs.rmSync(lock, {recursive:true, force:true}); }
298console.log(JSON.stringify({armed:arm, guard, helper, settings, newlyRegistered, todoTools, root:cwd}));
299" && echo "✅ wall install step done (see armed field above)" || echo "❌ FAILED install wall"
300```
301
302> **Root, lock, backup — the three invariants every settings-writing block here shares.**
303> `ROOT` is the canonical recipe (`CLAUDE_PROJECT_DIR` → `git rev-parse --show-toplevel` → upward
304> walk for `.git`/`.claude` → abort). An installer that cannot name its root ABORTS non-zero and
305> says what it looked for — it never writes to a guessed root, and it never uses raw `$PWD`, which
306> drifts to whatever subdirectory the session wandered into. The `settings.local.json.lock`
307> directory is an `O_EXCL` mutex (stale-broken after 30 s) so two setup skills running in parallel
308> cannot lose each other's edit; the file is re-read INSIDE the lock. `settings.local.json.bak` is
309> written before every rename. A read that is not `ENOENT`, or content that is not a JSON object,
310> ABORTS before anything is staged — a malformed settings file is never "the file is empty".
311
312After the block:
313- If `armed:true` → tell the user the exit command verbatim, with the real absolute path: `node <ABS project root>/.claude/brewtools/manager/manager-state.mjs set hard=false` — or just `/brewtools:manager-setup disable`, which runs exactly that.
314- If `armed:false` → tell the user plainly: **installed but NOT armed** — the guard is in place but `state.hard=false`; run `/brewtools:manager-setup enable` (or answer "Yes, arm it now" next time) to arm it.
315- If `newlyRegistered:true` → tell the user verbatim: `Hook installed in .claude/settings.local.json — run /reload (or restart the session) for the wall to take effect.`
316- If `newlyRegistered:false` → the entry already existed; a reload is only needed if `armed:true` just flipped a previously-disarmed state.
317- Report `todoTools` in one line: `enabled` → say `TaskCreate/TaskUpdate/TaskGet/TaskList enabled via env.CLAUDE_CODE_ENABLE_TODO_TOOLS in .claude/settings.local.json`; `skipped` → print the reason verbatim and move on.
318
319> The command in the registered entry uses an ABSOLUTE path to the copied guard and a `# brewtools-manager-guard` tag comment so `uninstall` can find it. Scope is always `project` — there is no global wall, never pass `'global'`.
320
321### upgrade (re-emit the guard from the current plugin version — arm state kept, provenance restamped)
322
323`upgrade` replays the install against the CURRENT plugin version so a `claude plugin update` finally reaches an already-installed project: it re-copies `hardmode-guard.mjs` **and `manager-state.mjs`**, re-registers the entry if it went missing, and — in the same read-merge-atomic-write of `settings.local.json` — sets `env.CLAUDE_CODE_ENABLE_TODO_TOOLS = "1"` on Claude Code >= 2.1.233. A project installed before the off-switch CLI existed has no project copy of `manager-state.mjs`, and one installed before the task-tool gate has no `env` key; `upgrade` is what backfills both, so run it once after updating brewtools. It asks nothing.
324
325> **It restamps `state.json`, and ONLY the metadata trio.** `setup-status` row 8 reads the
326> top-level `"version"` of `.claude/brewtools/manager/state.json` as the headline; the guard's
327> `brewcode-meta:` line is SECOND precedence, consulted only when that key is absent. So an
328> upgrade that re-copied the guard but left `state.json` alone reported the old version forever
329> and `status` printed `stale` after every `upgrade` — the staleness could never be cleared.
330> The fix is the docsync-setup shape (`brewdoc/skills/docsync-setup/SKILL.md` mode `upgrade`):
331> call `writeState('project', {}, cwd)` — an EMPTY partial. `writeState` merges
332> `{...existing, ...partial}` and then stamps `version` / `generated_by` / `last_updated`, so with
333> nothing in the partial it rewrites the trio and **nothing else**. `hard` and `level` are
334> preserved byte-for-byte out of the existing file: a disarmed wall stays disarmed, an armed one
335> stays armed, a customized `level` survives. That is what `stateUntouched` used to promise and it
336> still holds for the ARM state — the block now reports `armStatePreserved` + `stateRestamped` so
337> the two are not conflated.
338
339It ABORTS when the project has no wall installed. `upgrade` must never be a back door that arms a wall the user never asked for — an uninstalled project is told to run `install`.
340
341**EXECUTE** using Bash tool:
342```bash
343SD="${CLAUDE_SKILL_DIR}"
344if [ -n "$SD" ] && [ -f "$SD/../../.claude-plugin/plugin.json" ]; then BT_ROOT=$(cd "$SD/../.." && pwd); else BT_ROOT=$(ls -d ~/.claude/plugins/cache/claude-brewcode/brewtools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/*$::'); fi
345[ -n "$BT_ROOT" ] || { echo "ERROR: cannot locate brewtools plugin root -- install/update brewtools first."; exit 1; }
346test -f "$BT_ROOT/hooks/hardmode-guard.mjs" || { echo "❌ BT_ROOT invalid: $BT_ROOT"; exit 1; }
347ROOT=$(if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then printf %s "$CLAUDE_PROJECT_DIR"; elif r=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$r" ]; then printf %s "$r"; else d=$PWD; while [ "$d" != "/" ]; do if [ -d "$d/.git" ] || [ -d "$d/.claude" ]; then printf %s "$d"; break; fi; d=$(dirname "$d"); done; fi)
348[ -n "$ROOT" ] || { echo "❌ cannot resolve project root — looked for CLAUDE_PROJECT_DIR, git toplevel, then .git/.claude above $PWD; nothing written"; exit 1; }
349CCVER=$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)
350node --input-type=module -e "
351import fs from 'node:fs'; import path from 'node:path';
352import {writeState, resolveStatePath} from '${BT_ROOT}/hooks/lib/manager-state.mjs';
353const cwd = '${ROOT}';
354const src = '${BT_ROOT}/hooks/hardmode-guard.mjs';
355const dir = path.join(cwd, '.claude', 'brewtools', 'manager');
356const guard = path.join(dir, 'hardmode-guard.mjs');
357const settings = path.join(cwd, '.claude', 'settings.local.json');
358const TAG = 'brewtools-manager-guard';
359const has = m => Array.isArray(m.hooks) && m.hooks.some(h => typeof h.command==='string' && (h.command.includes(TAG) || h.command.includes('hardmode-guard.mjs')));
360// Task graph: CC 2.1.233+ gates TaskCreate/Update/Get/List off unless env.CLAUDE_CODE_ENABLE_TODO_TOOLS
361// is set. Numeric compare, never string. Below 2.1.233 the key is a no-op, so skip the write.
362const ccVer = '${CCVER}';
363const geVersion = (v, t) => { const a = String(v).split('.').map(n => parseInt(n, 10)); return a.length === 3 && !a.some(Number.isNaN) && (a[0] - t[0] || a[1] - t[1] || a[2] - t[2]) >= 0; };
364const todoToolsGated = geVersion(ccVer, [2,1,233]);
365const todoTools = todoToolsGated ? 'enabled (CC ' + ccVer + ')'
366 : ccVer ? 'skipped — CC ' + ccVer + ' predates the 2.1.233 gate, task tools are on by default'
367 : 'skipped — could not read the Claude Code version; on 2.1.233+ set env.CLAUDE_CODE_ENABLE_TODO_TOOLS=1 by hand';
368const lock = settings + '.lock';
369fs.mkdirSync(path.dirname(settings), {recursive:true});
370let held = false;
371for (let i = 0; i < 50 && !held; i++) {
372 try { fs.mkdirSync(lock); held = true; }
373 catch {
374 try { if (Date.now() - fs.statSync(lock).mtimeMs > 30000) { fs.rmSync(lock, {recursive:true, force:true}); continue; } } catch {}
375 Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
376 }
377}
378if (!held) { console.error('ABORT: ' + lock + ' is held by another setup skill — retry in a moment; nothing was written'); process.exit(1); }
379let newlyRegistered = false;
380try {
381 let cfg = {};
382 try { cfg = JSON.parse(fs.readFileSync(settings,'utf8')); }
383 catch (e) { if (e.code !== 'ENOENT') { console.error('ABORT: ' + settings + ' unreadable or invalid JSON (' + e.message + ') — fix it by hand; nothing was written'); process.exit(1); } }
384 if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) { console.error('ABORT: ' + settings + ' is not a JSON object — fix it by hand; nothing was written'); process.exit(1); }
385 const arr = (cfg.hooks && Array.isArray(cfg.hooks.PreToolUse)) ? cfg.hooks.PreToolUse : [];
386 if (!arr.some(has) && !fs.existsSync(guard)) { console.error('ABORT: the wall is not installed in this project — run install instead'); process.exit(1); }
387 fs.mkdirSync(dir, {recursive:true});
388 fs.copyFileSync(src, guard);
389 fs.copyFileSync('${BT_ROOT}/hooks/lib/manager-state.mjs', path.join(dir, 'manager-state.mjs'));
390 if (!arr.some(has)) { arr.push({ matcher:'*', hooks:[{ type:'command', command:\`node \"\${guard}\" # \${TAG}\`, timeout:5 }] }); newlyRegistered = true; }
391 cfg.hooks = (cfg.hooks && typeof cfg.hooks==='object') ? cfg.hooks : {};
392 cfg.hooks.PreToolUse = arr;
393 if (todoToolsGated) { cfg.env = (cfg.env && typeof cfg.env==='object' && !Array.isArray(cfg.env)) ? cfg.env : {}; cfg.env.CLAUDE_CODE_ENABLE_TODO_TOOLS = '1'; }
394 if (fs.existsSync(settings)) fs.copyFileSync(settings, settings + '.bak');
395 const tmp = settings + '.tmp';
396 fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
397 fs.renameSync(tmp, settings);
398} finally { fs.rmSync(lock, {recursive:true, force:true}); }
399// Restamp the metadata trio ONLY — empty partial, so hard/level/mode and every
400// unknown key merge through from the existing file untouched.
401let before = null;
402try { before = JSON.parse(fs.readFileSync(resolveStatePath('project', cwd),'utf8')); } catch {}
403const w = await writeState('project', {}, cwd);
404const armStatePreserved = !before || (w.state.hard === before.hard && w.state.level === before.level);
405console.log(JSON.stringify({guardReplaced:true, guard, newlyRegistered, todoTools,
406 stateRestamped:{version:w.state.version, generated_by:w.state.generated_by, last_updated:w.state.last_updated},
407 hard:w.state.hard, level:w.state.level, armStatePreserved}));
408" && echo "✅ wall upgraded (arm state preserved, state.json restamped)" || echo "❌ FAILED upgrade"
409```
410
411Surface the `/reload` note only when `newlyRegistered:true`. Report `todoTools` in one line the same way `install` does — an old project that predates the key gets it backfilled here.
412
413### enable (ARM an installed wall — state flip only)
414
415**Gated by P1.** This block only runs if the P1 arm-confirmation gate resolved `ARM=true` (explicit
416wording in the user's prompt, or an `AskUserQuestion` answered "Yes, arm it now"). If P1 resolved
417`ARM=false`, do NOT run this block at all — report plainly that arming was declined and nothing
418changed, and stop.
419
420`enable` flips `state.hard=true` and nothing else. If the wall was never installed there is no guard to arm, so the block reports `notInstalled` instead of writing a state
421
422…(truncated)