Installer/configurator skill. It wires two self-contained hooks (PreToolUse guard + SubagentStop cleanup) that put a SOFT wall-clock budget on every subagent — or configures/removes them. All runtime behavior lives in the hook files and in a JSON config; this skill only decides mode, scope and budget, then delegates the file work to the
brewcode:hook-creatoragent following the runbook.
Claude Code has no wall-clock timeout for subagents. maxTurns stops a run at its turn cap; since 2.1.246 the caller receives a partial-marked result with a continuation hint, while unwritten analysis remains unrecoverable. These hooks kill nothing: at 80% of the budget the agent gets one non-blocking "wrap up" directive; past 100% every tool except the finalization set is denied, directing the agent to finish its report while finalization tools remain available.
Prompt contract
Treat $ARGUMENTS as a free-form RU/EN prompt. Modes/flags may appear anywhere; infer mode and scope from prose.
- Strip flags. An explicit mode token anywhere wins outright, no scoring.
- Else score modes by distinct whole-word keyword hits (table in Step 2). Highest unique score
wins. Tie with a destructive mode (
purge) ->AskUserQuestion; tie withstatus->status; tie of two mutating modes -> the keyword appearing first; all zero ->status. - Empty arguments ->
status; ask ONE scopingAskUserQuestiononly when the answer changes what gets written. A read-only run asks nothing. - Outcome-changing ambiguity -> ONE
AskUserQuestion(max 4 questions) BEFORE any work. - Prose that is not a mode/id/path is still input: extract the id, path or target from it.
Then print this block ONCE, before the first action:
PLAN — brewtools:agent-deadline-setup
INPUT: <arguments verbatim, or "(empty)">
MODE: <resolved> — <explicit | matched keyword: X | default>
SCOPE: <resolved paths / target / level / flags>
DO: <2-5 imperative bullets>
RESULT: <what the user ends up holding>
Labels are literal; values follow the conversation language.
What the hooks do (informational — skill does NOT implement)
| Hook | Event | Behavior |
|---|---|---|
agent-deadline-guard.mjs |
PreToolUse (.*) |
tracks elapsed per agent_id; 80% -> one additionalContext warning; 100% -> permissionDecision:"deny" for everything outside the finalization set; hardStopRatiox budget (default 2x) -> allowance shrinks to Write, Edit |
agent-deadline-cleanup.mjs |
SubagentStop | deletes the finished agent's state file |
Finalization set — advertised in the guard's directives: Read, Write, Edit, MultiEdit, NotebookEdit, TodoWrite, TaskUpdate.
Actually allowed past 100%: those 7 plus TaskCreate, BashOutput, TaskOutput. The 3 extras are deliberately NOT named in the directive text — naming BashOutput invites a poll loop, while an agent that genuinely needs to harvest an in-flight job still gets through. Declared list ⊂ real list is by design, not a bug.
AskUserQuestion is DENIED on purpose: a subagent parked on a human answer is unbounded wall-clock time, exactly the failure this guard exists to stop.
Hard stop. Past hardStopRatio x budget (config key, default 2, must be >1) the allow-set shrinks from the finalize set to Write, Edit only, and the deny reason changes to AGENT DEADLINE HARD STOP. This catches the agent that loops inside the finalize set (re-reading files, rewriting todos) instead of finishing.
Honest limits (verified on CC 2.1.223 — state these to the user, do not oversell)
| Fact | Consequence |
|---|---|
| Time is sampled ONLY at tool-call boundaries | An agent stuck inside one 25-min Bash call is not observed in between. This is a soft deadline, NOT a timeout — cap long commands with BASH_MAX_TIMEOUT_MS. |
| Clock starts at the agent's FIRST tool call, not at spawn | Pre-tool thinking time is free. |
The subagent-spawn tool is named Agent in the payload, not Task |
Matters when matching payloads / writing sibling hooks. |
Main session vs subagent is discriminated by absence of agent_id/agent_type |
Main session = no-op, always. |
agent_type for plugin agents (brewtools:text-optimizer vs text-optimizer) was NOT observed live |
A byAgentType key that does not match the real payload value silently falls back to defaultMinutes. Verify against a real payload before relying on an override. |
| Hook is fail-open | Any error = the call passes through; the session never breaks. |
| Cost per tool call: median 58.3 ms, p90 62.5 ms (measured: Apple M-series, Node v24.1.0, 30 runs) | Node startup dominates; on top of it the guard does up to 19 readFileSync — stdin payload, up to 16 project-config probes (walk from cwd to the filesystem root), global config, state file. |
The PreToolUse matcher is .* |
The tax is paid by EVERY tool call, not only subagent ones. The main-session no-op path measured 61.5 ms. A global install therefore charges ~60 ms to every tool call of every session in every repo, including sessions that never spawn a subagent. State this before installing globally, not after. |
BT_ROOT Resolver (use in EVERY bash block)
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:
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 -d "$BT_ROOT/skills/agent-deadline-setup/assets" || { echo "❌ FAILED — BT_ROOT invalid: $BT_ROOT"; exit 1; }
Asset paths (all under $BT_ROOT/skills/agent-deadline-setup/assets/):
INSTALL.md— the runbook: install project/global, config shape, disable/enable, uninstall, purge, verify. Single source of truth — follow it, never re-derive its commands here.agent-deadline-guard.mjs,agent-deadline-cleanup.mjs— the two hook files that travel together
Never use
Write/Editon~/.claude/*— protected path, blocked in ALL modes. Global operations run through the Bash tool only (cp/node/rm). The hook-creator agent handles this per the runbook.
Opt-in by design: these hooks are NOT registered in
brewtools/hooks/hooks.json, so installing the plugin does nothing until this skill runs.
Step 1 — STATUS FIRST, always
After the initial PLAN, probe status in EVERY mode before installation, reinstallation, or removal.
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; }
A="$BT_ROOT/skills/agent-deadline-setup/assets"
test -f "$A/INSTALL.md" && test -f "$A/agent-deadline-guard.mjs" && test -f "$A/agent-deadline-cleanup.mjs" || { echo "❌ FAILED — assets incomplete under BT_ROOT=$BT_ROOT"; exit 1; }
echo "ASSETS_DIR=$A"
echo "RUNBOOK=$A/INSTALL.md"
content_state() {
ASSETS="$A" TARGET="$1" node <<'NODE'
const fs=require("fs"), path=require("path");
const assets=process.env.ASSETS, target=process.env.TARGET;
const scripts=["agent-deadline-guard.mjs","agent-deadline-cleanup.mjs"];
const file=path.join(target,"agent-deadline.json");
const semver=/^[0-9]+\.[0-9]+\.[0-9]+$/;
const cvOf=f=>{try{return /content_version=([0-9]+\.[0-9]+\.[0-9]+)(?=\s|$)/.exec(fs.readFileSync(f,"utf8").split("\n").slice(0,3).join("\n"))?.[1]||null;}catch{return null;}};
let configCV=null;
try{const c=JSON.parse(fs.readFileSync(file,"utf8")); if(c&&typeof c==="object"&&!Array.isArray(c)&&typeof c.content_version==="string"&&semver.test(c.content_version)) configCV=c.content_version;}catch{}
const runbookCV=cvOf(path.join(assets,"INSTALL.md"));
const pairs=scripts.map(script=>[cvOf(path.join(target,"hooks",script)),cvOf(path.join(assets,script))]);
const findings=[];
if(!configCV||!runbookCV||configCV!==runbookCV) findings.push("agent-deadline.json:"+(configCV||"n/a")+"->"+(runbookCV||"n/a"));
pairs.forEach(([installed,source],i)=>{if(!installed||!source||installed!==source) findings.push(scripts[i]+":"+(installed||"n/a")+"->"+(source||"n/a"));});
let state="missing";
if(fs.existsSync(file)||scripts.some(script=>fs.existsSync(path.join(target,"hooks",script)))) {
if(!configCV||!runbookCV||pairs.some(([installed,source])=>!installed||!source)) state="partial";
else state=configCV!==runbookCV||pairs.some(([installed,source])=>installed!==source)?"stale":"current";
}
console.log([configCV||"n/a",runbookCV||"n/a",state,findings.join(",")||"none"].join("|"));
NODE
}
claude_project_root() {
if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then
printf '%s\n' "$CLAUDE_PROJECT_DIR"; return 0
fi
if r=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$r" ]; then
printf '%s\n' "$r"; return 0
fi
d=$PWD
while [ "$d" != "/" ]; do
if [ -d "$d/.git" ] || [ -d "$d/.claude" ]; then printf '%s\n' "$d"; return 0; fi
d=$(dirname "$d")
done
printf '%s\n' "$PWD"; return 1
}
if ROOT=$(claude_project_root); then ROOT_OK=yes; else ROOT_OK=no; fi
echo "project_root=$ROOT root_resolved=$ROOT_OK"
for S in "$ROOT/.claude:project" "$HOME/.claude:global"; do
D="${S%%:*}"; N="${S##*:}"
G=no; [ -f "$D/hooks/agent-deadline-guard.mjs" ] && G=yes
C=no; [ -f "$D/hooks/agent-deadline-cleanup.mjs" ] && C=yes
REFS=$(SETTINGS="$D/settings.json" SCOPE="$N" HOOKS_DIR="$D/hooks" node <<'NODE'
const fs=require("fs"), path=require("path");
const f=process.env.SETTINGS, scope=process.env.SCOPE, dir=process.env.HOOKS_DIR;
const marks=["agent-deadline-guard.mjs","agent-deadline-cleanup.mjs"];
let s={};
let settingsValid=true;
try{
if(fs.existsSync(f)&&fs.readFileSync(f,"utf8").trim()) s=JSON.parse(fs.readFileSync(f,"utf8"));
if(s===null||typeof s!=="object"||Array.isArray(s)) settingsValid=false;
}catch{ settingsValid=false; s={}; }
const expected=marks.map(m=>scope==="project"?"${CLAUDE_PROJECT_DIR}/.claude/hooks/"+m:path.join(dir,m));
const specs={
"agent-deadline-guard.mjs":{event:"PreToolUse",matcher:".*",arg:expected[0],timeout:5},
"agent-deadline-cleanup.mjs":{event:"SubagentStop",matcher:null,arg:expected[1],timeout:3},
};
const argsOf=h=>Array.isArray(h&&h.args)?h.args.filter(a=>typeof a==="string"):[];
const ownedScript=h=>{
let body="";
try{ body=JSON.stringify(h); }catch{}
return marks.find(m=>body.includes(m));
};
const matcherIs=(entry,matcher)=>matcher===null?!Object.prototype.hasOwnProperty.call(entry,"matcher"):entry.matcher===matcher;
const exactKeys=h=>h&&typeof h==="object"&&!Array.isArray(h)&&Object.keys(h).sort().join(",")==="args,command,timeout,type";
const wired={"agent-deadline-guard.mjs":0,"agent-deadline-cleanup.mjs":0};
let legacy=0;
for(const [event,entries] of Object.entries((s&&s.hooks)||{})){
if(!Array.isArray(entries)) continue;
for(const entry of entries){
if(!entry||typeof entry!=="object"||!Array.isArray(entry.hooks)) continue;
for(const handler of entry.hooks){
const script=ownedScript(handler);
if(!script) continue;
const spec=specs[script];
const exact=settingsValid&&event===spec.event&&matcherIs(entry,spec.matcher)&&exactKeys(handler)&&handler.type==="command"&&handler.command==="node"&&argsOf(handler).length===1&&argsOf(handler)[0]===spec.arg&&handler.timeout===spec.timeout;
if(exact) wired[script]+=1; else legacy+=1;
}
}
}
console.log(wired[marks[0]]+"|"+wired[marks[1]]+"|"+legacy+"|"+(settingsValid?"yes":"no"));
NODE
)
GR=${REFS%%|*}; REST=${REFS#*|}; CR=${REST%%|*}; REST=${REST#*|}; LEGACY=${REST%%|*}; VALID=${REFS##*|}
CFG=none; [ -s "$D/agent-deadline.json" ] && CFG=$(tr -d '\n ' < "$D/agent-deadline.json"); CFG=${CFG:-none}
EN=n/a; case "$CFG" in *'"enabled":true'*) EN=true;; *'"enabled":false'*) EN=false;; esac
CV=$({ jq -r '.version // empty' "$D/agent-deadline.json" 2>/dev/null || true; }); CV=${CV:-n/a}
META=$(content_state "$D"); CCV=${META%%|*}; REST_META=${META#*|}; RCV=${REST_META%%|*}; REST_META=${REST_META#*|}; CONTENT=${REST_META%%|*}; FINDINGS=${REST_META#*|}
echo "$N: guard=$G cleanup=$C guard_refs=$GR cleanup_refs=$CR legacy_refs=$LEGACY settings_valid=$VALID enabled=$EN config_version=$CV config_content_version=$CCV runbook_content_version=$RCV content_state=$CONTENT content_findings=$FINDINGS config=$CFG"
done
PV=$({ jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true; }); PV=${PV:-n/a}
echo "plugin_version=$PV"
echo "✅ status"
STOP if ❌ — plugin cache incomplete; reinstall/update brewtools first.
Field meanings — do not paraphrase them into something stronger:
| Field | Value |
|---|---|
project_root / root_resolved |
project status resolves CLAUDE_PROJECT_DIR, then git toplevel, then an owning .git/.claude ancestor; root_resolved=no means read-only fallback to $PWD |
guard / cleanup |
yes/no — hook FILE present in that scope's hooks/ |
guard_refs / cleanup_refs |
separate exact desired handler counts. Guard = PreToolUse + .* + command/node/sole portable arg + timeout 5; cleanup = SubagentStop + no matcher + command/node/sole portable arg + timeout 3; global args use expanded ~/.claude/hooks/<script>. Each must be exactly 1; two guards never substitute for a missing cleanup |
legacy_refs |
owned handlers that differ from a complete desired tuple, including absolute paths, swapped events, wrong matchers/types/commands/timeouts, extra args/keys, or malformed handler values; any nonzero value requires migration |
settings_valid |
yes only when settings are absent/empty or parse as a JSON object; malformed JSON/shape is non-effective |
enabled |
true/false parsed from the config; n/a = no config or no enabled key |
config_version |
release provenance only; compare with plugin_version for display, never for staleness. A matching release cannot prove current hook/config content |
config_content_version / runbook_content_version |
installed config CV versus the authoritative INSTALL.md header; each installed hook is also compared with its corresponding asset header |
content_state |
current: all config/hook CV pairs match, regardless of release provenance; stale: at least one valid pair differs; partial: an installed component has missing/malformed CV or a required counterpart is absent; missing: no config or hook files in that scope. Never infer fresh CV from release version or a missing key |
content_findings |
comma-separated artifact:installed-CV->source-CV evidence for every mismatched/missing pair, or none; n/a is explicit missing/malformed metadata, never a guessed fresh version |
config |
whitespace-stripped config contents, or literal none |
The status probe parses JSON and validates each script separately against the exact event, matcher, handler keys, type, command, sole arg, and timeout tuple. Duplicates remain visible as counts above 1 and are non-effective.
Print the status in this table:
| Scope | Hook files | guard refs | cleanup refs | legacy refs | settings valid | Config | Config ver | Content state | Effective |
|---|
Config metadata (the four standard JSON keys)
Every mode that writes agent-deadline.json (install, upgrade, enable, disable) leaves these four keys alongside behavior keys. doc_type belongs only in .md frontmatter:
{ "version": "{PLUGIN_VERSION}", "content_version": "<INSTALL.md header>", "generated_by": "brewtools:agent-deadline-setup", "last_updated": "{LAST_UPDATED}" }
The runbook resolves content_version from its own brewcode-meta: header via $RUNBOOK and aborts if missing; never pass or hardcode it. Resolve version and last_updated here. 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; }
PV=$(jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true)
PV=${PV:-$(basename "$BT_ROOT")}
echo "PLUGIN_VERSION=$PV LAST_UPDATED=$(date +%F)"
Bare substitution required. CC 2.1.226 substitutes only the literal
${CLAUDE_SKILL_DIR}in skill prompts (replace(/\$\{CLAUDE_SKILL_DIR\}/g, dirname(skillPath))/replaceAll), not env values or:-fallback; a modifier reaches Bash unchanged and ALWAYS selects its fallback.${CLAUDE_PLUGIN_ROOT}is exported to hooks/MCP, never skill Bash. The skill path works in cache and--plugin-dirinstalls; the cache glob is a last resort and selects the installed plugin.
| Guarantee | Why it holds |
|---|---|
| The hooks ignore them | loadConfig() accepts any non-array JSON object and reads only enabled, defaultMinutes, byAgentType, hardStopRatio; unknown keys are inert |
enabled semantics unchanged |
The gate stays cfg.enabled !== true -> off. Adding sibling keys touches nothing |
| Cannot make a valid file unparseable | Written by the runbook's node block that re-serializes the whole object with JSON.stringify — never appended as raw text. An invalid project config is skipped and the GLOBAL one takes over, which is a silent behavior change, so a hand-appended line is a defect |
Content state and enabled/wiring are separate signals. Offer upgrade for stale or partial, naming the mismatched/missing metadata or artifact; do not offer it from a release-version difference alone. A deliberately disabled setup remains disabled, even if its content needs repair; upgrade must preserve that choice. Status never edits files or settings.
Effective = guard=yes cleanup=yes guard_refs=1 cleanup_refs=1 legacy_refs=0 settings_valid=yes enabled=true. Anything else, including duplicate-one/missing-other registrations or a malformed owned handler, is NOT effective — say so plainly instead of reporting a half-state as installed. Project config wins over global; a broken project config is skipped and global is used.
Early exit
If everything the user could want is already installed and the intent is not explicit (no argument, or vague like "agent-deadline"), PRINT the status, list the operations available (upgrade, enable, disable, change budget, uninstall, purge, install for the other scope) and STOP. Do not re-install, do not ask a chain of questions.
Step 2 — Decide MODE
Read $ARGUMENTS. Default when there are NO arguments at all = status.
| Mode | EN keywords | RU keywords | Mutates? |
|---|---|---|---|
status |
(empty), status |
статус, проверь, что стоит |
no |
install |
install, set up, bare number of minutes |
поставь, установи, включи дедлайн |
yes |
upgrade |
upgrade, update, refresh |
обнови, перевыстави, после обновления плагина |
yes |
enable |
enable |
включи обратно, верни |
yes |
disable |
disable |
выключи, отключи, паузу |
yes |
uninstall |
uninstall |
убери, сними, удали хук |
yes |
purge |
purge, wipe, remove everything |
вычисти всё, удали полностью, убери совсем, снеси |
yes, destructive |
Ambiguous between install and a removal verb → AskUserQuestion. Never guess a destructive mode.
Step 3 — Explain pending changes before questions
Add concrete state/action details to the initial PLAN without repeating its block. Example:
Current state: agent-deadline not installed anywhere. Plan: copy the 2 hook files into
<repo>/.claude/hooks/, write<repo>/.claude/agent-deadline.json, merge two entries (PreToolUse + SubagentStop) into<repo>/.claude/settings.json. I need 2 answers first: scope and budget.
Step 4 — Ask ONLY what is missing (AskUserQuestion)
Skip any question already answered by $ARGUMENTS or settled by the status table.
| # | Question | Options | Default |
|---|---|---|---|
| 1 | Scope — this project or all projects? | Project (<repo>/.claude) / Global (~/.claude) / Both |
none — NEVER guess, always ask unless explicit |
| 2 | Deadline budget per subagent? | 20 min (Recommended) / 30 min / 45 min / 10 min | 20 |
| 3 | Per-agent-type overrides? | Uniform limit for all agents (Recommended) / Define overrides | uniform, byAgentType: {} |
When question 1 is asked, the Global option description MUST carry the cost: .* matcher = ~58 ms median added to every tool call of every session, main sessions included.
Question 3 is asked ONLY if the user brought up per-type limits themselves; otherwise install uniform and mention in the final report that overrides exist. If overrides ARE requested, warn verbatim: agent_type for plugin agents (e.g. brewtools:text-optimizer vs text-optimizer) has not been observed live — a key that does not match the real payload silently falls back to defaultMinutes. Verify against a real payload first.
For disable/enable/uninstall/purge only question 1 applies, and only when the status table shows the feature present in more than one scope.
Step 5 — Act on the resolved PLAN
Use the PLAN already printed before Step 1; do not print it twice. Resolve MODE/SCOPE, exact paths, defaultMinutes, and settings.json entries before mutation. For uninstall/purge, list the exact deletions and confirm once. Status and early-exit modes report the table and stop; DO: is "read state, report".
Delegation
One mode × one scope is ONE bounded unit (2 asset files + one settings.json + one config) — a single hook-creator spawn. "Both scopes" = TWO tasks, spawned in ONE message.
Every brief must retain GOAL, ROLE (ownership/exclusions), SCOPE (paths/commands), CONTEXT (completed/parallel work), CONSUMER (next use/output shape), and DONE (acceptance/report).
Export before every runbook Bash block.
MINUTES/OVERRIDES/RUNBOOKmust reachprocess.env; missingMINUTESaborts (no built-in20fallback). Substitute chosen values into both CONTEXT and the literal export below; prose alone cannot set the environment.
Spawn (substitute MODE, SCOPE, MINUTES, OVERRIDES, HARD_STOP_RATIO, RUNBOOK, ASSETS_DIR, PLUGIN_VERSION, LAST_UPDATED from Steps 1-4 and the Config-metadata block — into BOTH the CONTEXT block and the export line):
Agent(subagent_type="brewcode:hook-creator", prompt="
GOAL: the user wants the agent-deadline hooks MODE-ed for SCOPE. Two hooks (PreToolUse
guard + SubagentStop cleanup) impose a soft wall-clock budget on subagents: warn at 80%,
deny non-finalization tools past 100%. Runtime behavior lives entirely in the hook files
and in agent-deadline.json, so this task is pure file + settings + config wiring.
ROLE: you own the file copy/removal, the settings.json merge/strip and the config write.
Do NOT edit hook logic, do NOT touch unrelated hooks or settings keys, do NOT act on the
other scope, do NOT register anything in the plugin's own hooks.json.
SCOPE: in — the 2 assets under ASSETS_DIR, the target .claude/ dir, its settings.json,
its agent-deadline.json. Out — everything else. Project scope: Write/Edit are fine.
Global scope (~/.claude/*): BASH ONLY (cp + node + rm), never Write/Edit — protected path.
CONTEXT:
Status was already collected and every path below resolved; nothing has been written yet.
MODE = MODE (install|upgrade|enable|disable|uninstall|purge)
SCOPE = SCOPE (project|global)
MINUTES = MINUTES (defaultMinutes for the config; install and upgrade only. For upgrade it
is the number ALREADY in that scope's config, read back by the runbook — never a new one)
OVERRIDES = OVERRIDES (byAgentType JSON object, {} unless the user asked otherwise)
HARD_STOP_RATIO = HARD_STOP_RATIO (multiple of the budget after which the allowance
shrinks to Write/Edit; omit entirely to keep the hook default of 2, must be > 1)
RUNBOOK = RUNBOOK (absolute path to assets/INSTALL.md)
ASSETS_DIR = ASSETS_DIR (absolute path to the assets source dir — copy the 2 hook files FROM here)
MANDATORY FIRST BASH COMMAND — the runbook's node blocks read these from the ENVIRONMENT,
not from this prompt. Run this VERBATIM as the first line of EVERY Bash call that executes
a runbook block (a new Bash call does NOT inherit exports from the previous one):
export RUNBOOK='RUNBOOK' MINUTES='MINUTES' OVERRIDES='OVERRIDES' HARD_STOP_RATIO='HARD_STOP_RATIO' PLUGIN_VERSION='PLUGIN_VERSION' LAST_UPDATED='LAST_UPDATED'
Then verify before writing anything:
echo \"MINUTES=\$MINUTES OVERRIDES=\$OVERRIDES HARD_STOP_RATIO=\$HARD_STOP_RATIO RUNBOOK=\$RUNBOOK PV=\$PLUGIN_VERSION LU=\$LAST_UPDATED\"
If MINUTES prints empty, STOP and report — the config block ABORTS on an empty MINUTES
by design; re-export it rather than hardcoding a number.
Drop HARD_STOP_RATIO from the export line when the user did not set it.
Follow the runbook at RUNBOOK exactly and use ITS commands — it self-locates its source via
SRC=\$(dirname \"\$RUNBOOK\"). Sections map 1:1
to MODE: 'PROJECT target'/'GLOBAL target' for install, 'UPGRADE', 'DISABLE / ENABLE',
'UNINSTALL', 'PURGE'. Merge strips owned handlers individually while preserving foreign
co-handlers, then appends exactly one handler per script. Project args use the literal
`${CLAUDE_PROJECT_DIR}/.claude/hooks/<script>` and migrate legacy absolute checkout args;
global args stay expanded absolute paths. Both paths are idempotent.
Upgrade = the 'UPGRADE' section: read MINUTES/OVERRIDES/HARD_STOP_RATIO back out of the
existing config, export them, then replay the copy + config + merge blocks for SCOPE.
Uninstall = strip handlers by those two basenames, drop empty entries/event arrays, delete the 2
files, KEEP the config. Purge = uninstall + delete config + tmp state.
METADATA: every mode that WRITES the config (install, upgrade, enable, disable) must leave
these four keys in agent-deadline.json: version=\$PLUGIN_VERSION,
content_version=<read by the runbook from its own brewcode-meta header; never hardcoded>,
generated_by=\"brewtools:agent-deadline-setup\", last_updated=\$LAST_UPDATED. No doc_type —
it is a .md-frontmatter field and never belongs in a JSON carrier. Set them INSIDE
the runbook's node block that re-serializes the object with JSON.stringify — never by
appending text to the file. An invalid project config is SKIPPED and the global one silently
takes over, so a hand-edited append is a defect, not a shortcut. Do NOT touch enabled while
doing it: the hooks require it to be exactly true.
CONSUMER: Step 6 reports your result to the user; the settings.json you write is loaded by
the NEXT Claude Code session, so a malformed merge breaks that session instead of failing
here — report the exact paths you touched so they can be checked.
DONE: report the settings.json path, the hooks dir, the config path with its final contents,
and the runbook 'Verify' output if you ran it. The reported config MUST show
defaultMinutes = MINUTES — a 20 where the user asked for something else is a FAILURE,
not a detail — and version = \$PLUGIN_VERSION. Prove the file still parses: jq . <config path>.
")
Step 6 — Final status
Re-run the Step 1 status block and print the refreshed table, plus:
- what changed (files, settings.json, config values),
- a NEW session is required for hook WIRING changes (install/upgrade/uninstall/purge) —
/reload-pluginsis not needed, these are plain settings.json hooks; - config VALUE changes (
enabled,defaultMinutes,byAgentType,hardStopRatio) are read live — no restart; - release
versionas provenance, config/runbookcontent_versionand the hook comparisons; reportcontent_state, never infer freshness from a release bump; - the soft-deadline caveat: time is sampled at tool-call boundaries only; pair with
BASH_MAX_TIMEOUT_MSfor long single commands.
Modes
| Mode | Effect | Files | settings.json | Config | State |
|---|---|---|---|---|---|
status |
report only | — | — | — | — |
install |
wire + configure | copied | entries merged | written | — |
upgrade |
re-emit from the current plugin version, budget preserved | re-copied | entries re-merged | values preserved | kept |
enable |
enabled:true |
kept | kept | edited | kept |
disable |
enabled:false |
kept | kept | edited | kept |
uninstall |
unwire | deleted | entries stripped | kept | kept |
purge |
full wipe | deleted | entries stripped | deleted | deleted |
upgrade asks nothing. It reads defaultMinutes, byAgentType and hardStopRatio back out of the existing config and replays the install for that scope, so a plugin update finally reaches the project with the SAME budget. A disabled setup stays disabled. Not installed in that scope -> it is an install, so ask scope and budget.
Guards
| Condition | Response |
|---|---|
BT_ROOT resolves but $BT_ROOT/skills/agent-deadline-setup/assets missing |
ERROR: agent-deadline: assets not found under $BT_ROOT — plugin cache incomplete. STOP. |
Neither the skill dir nor any cached plugin dir yields .claude-plugin/plugin.json |
ERROR: agent-deadline: cannot locate plugin root — install/update brewtools first. STOP. |
| Status shows fully installed + vague intent | Print status, list available operations, STOP. Do not re-install. |
| Scope unspecified | AskUserQuestion: Project / Global / Both. Never guess. |
| Global scope chosen (or asked about) | BEFORE writing anything, state the cost: matcher is .*, so ~58 ms median (p90 62.5 ms) is added to EVERY tool call of EVERY session in EVERY repo, main sessions included. Say it in the question or the plan, never only in the final report. |
| Mode ambiguous between install and removal | AskUserQuestion. Never guess a destructive mode. |
| Install delegated with a non-default budget | The spawn prompt MUST contain the literal export MINUTES='<chosen>' OVERRIDES='<chosen>' RUNBOOK='<path>' line. Values described only in prose never reach the runbook's process.env; the config block then ABORTS on an empty MINUTES instead of writing 20. Check the agent's reported config for the chosen number. |
uninstall/purge requested |
Restate exactly what gets deleted, confirm once, then delegate. |
| Global scope | hook-creator MUST use Bash only — ~/.claude/* is protected. |
| Project config exists but is malformed JSON | Report it: the guard skips it and silently falls back to the GLOBAL config. Offer to rewrite. |
User asks for byAgentType overrides |
Warn that plugin-scoped agent_type values are unverified and a mismatch falls back to defaultMinutes. |
Smoke Test
Verify the 3 assets exist and the hooks parse before delegating.
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; }
A="$BT_ROOT/skills/agent-deadline-setup/assets"
test -d "$A" || { echo "❌ smoke FAILED — assets dir missing: $A"; exit 1; }
for f in agent-deadline-guard.mjs agent-deadline-cleanup.mjs INSTALL.md; do
test -f "$A/$f" || { echo "❌ smoke FAILED — missing $f"; exit 1; }
done
node --check "$A/agent-deadline-guard.mjs" && \
node --check "$A/agent-deadline-cleanup.mjs" && \
echo "✅ smoke" || echo "❌ smoke FAILED — syntax error in a hook file"
STOP if ❌ — do NOT delegate; reinstall/update brewtools first.
node --check proves the files parse, nothing more. Behavioral verification (synthetic payloads, forced deadline) is in the runbook's Verify section and is NOT run here.