Harness Mod layer: heartbeat/canary, native usage, rewrite explanations, measured subagent accounting, supervisor routing, secret redaction. Modes per guard in…

One harness. Every client. One repository.
Agnostic AI is the operating system for an AI coding harness: the guard hooks that block secrets and destructive commands, the working agreement (rules), the on-demand context modules, subagents, saved workflows, Mods (function-hook plugins), operator tools, scheduled jobs and the learning loop that turns incidents into rules. It is installed into the client you use (Claude Code) as links, and ported from there into every other client on the machine (Codex, Gemini CLI, Cursor and sixteen more) in each one's dialect. Your identity, private rules, memory and machine config stay in a small private overlay of your own.
It is not a starter kit designed in an afternoon. It grew rule by rule out of daily use, and most of it exists because something broke first: every guard has an incident behind it, every doc has a word ceiling, every check reports the count it processed, and a check that was never seen failing does not count as verified.
This repository is also published as ucsandman/claude-harness, the name it was first shared under. Both receive every push; they are the same commits.
git clone https://github.com/ucsandman/Agnostic-AI.git && cd Agnostic-AI
npm run setup # wire the core guards, link the surfaces, assemble CLAUDE.md, port, doctor
Node 18+ and git, nothing else. setup compiles core/rules into ~/.claude/agnostic-rules.md, generates a CLAUDE.md that imports it, wires the core guards into settings.json, links ~/.claude/{hooks,tools,mods,agents,workflows} into this checkout (a real directory there is moved aside, never deleted), ports the harness to every other installed client and runs the doctor (CLAUDE_CONFIG_DIR moves the home). The three commands you keep using:
npm run sync # rules changed, or a link is missing: recompile, relink, reassemble CLAUDE.md
npm run port # push the harness to every other installed client
npm run doctor # drift, a second writer, a broken link, an old path, a private path in public code
To use it as a template: click Use this template, clone, npm run setup. core/port.json chooses the source client, restricts targets, or excludes a hook, skill or MCP server with a reason.
| Directory | What it holds | Size |
|---|---|---|
core/ | The source of truth: rules/global-rules.md and its on-demand modules, templates/targets.json (the client registry), safety/guards.json (the one safety policy), traits/, port.json, incident examples/ | 7 modules, 20 clients |
engine/ | The port engine and everything that runs: harness/ (capture, apply, status), sync/ (rules compiler, link binder, CLAUDE.md assembly), setup/ (first run, links), doctor/, context/ (the module graph), hooks/ (the guards, their probes, the client shim), mods/, harvest/ and distill/ (the learning loop), ingest/, skills/, audit/, docs/ (generators), tests/ | 14 subsystems, 39 hooks |
agents/ | Subagent definitions with model, tools and scope | 6 |
workflows/ | Saved Workflow scripts for multi-agent work | 4 |
tools/ | Operator CLIs and pages: measure, search, prove, render | 25 |
jobs/ | Scheduled jobs and the installer that points Task Scheduler at them | 6 |
skills/ | The consolidated skill library, linked (never copied) into each client | 216 |
packages/ | markdown-agent-memory, the published memory policy, templates and linter | 1 |
docs/ | The documentation, each file under a word ceiling the pre-commit hook enforces | 29 |
labs/ | Research: the Mods sprint record, detached builder, process ledger, token-flow audit. Nothing in engine/ depends on it | 4 |
examples/ | An installed harness for reference | 1 |
harness/, storage/ | Your captured bundle and runtime state, both gitignored |
Dependency direction is one way: core to engine to the installed surfaces to the client homes. The overlay is read through three named files. Every generated file has one writer, and the doctor fails on a second one.
flowchart LR
core["core/<br/>rules, modules, targets.json,<br/>guards.json, port.json"] --> engine["engine/<br/>sync, setup, doctor, context graph,<br/>hooks, Mods, harness port"]
overlay["overlay/ (private)<br/>profile.md, context-graph.json, gates.json"] -. read by sync .-> engine
engine --> home["~/.claude<br/>hooks, tools, mods, agents, workflows as links<br/>agnostic-rules.md, CLAUDE.md generated"]
home -- capture --> bundle["harness/ bundle<br/>rules, identity, hooks, skills,<br/>agents, commands, mcp, permissions"]
bundle -- apply --> clients["19 other clients<br/>Codex, Gemini, Cursor, Windsurf, Cline, ..."]
clients -- one shim --> guards["engine/hooks/<br/>the same guard scripts everywhere"]
@import inlined, hooks in one dialect, skills, agents, commands, MCP servers, permissions. A value that looks like a token becomes ${NAME} and you are told what to export.--check exits 1 on drift.npm run explain, from core/port.json.The three places in full, with the storage layout and the adapter contract: docs/architecture.md.
engine/hooks/ holds 41 hooks: 38 Node, 2 Python, 1 PowerShell. They run on Claude Code's events (PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionStart, SubagentStart, PreCompact) and, through engine/hooks/shim.cjs, on every other client that has hooks. One file, core/safety/guards.json, is the policy every guard reads: secret paths are always blocked, hard-stop commands need a human, a missing policy fails closed.
| Group | Hooks | What they do |
|---|---|---|
| Secrets | secret-guard, secret-path-guard, tool-output-secret-watch, output-secret-watch | Deny reads and writes of secret files, redact secret-looking values in tool output and in what is displayed |
| Destructive commands | rm-guard, process-kill-guard, git-tree-guard, dev-server-guard, slow-command-guard, slopsquat-guard, security-tier-check | Recursive deletes outside scratch need a marker, kills are by PID not by name, no recursive search from a drive root, package names that look hallucinated are refused |
| Model routing and cost | agent-model-guard, capability-graph-guard, subagent-budget-guard, fable-delegate-guard, batch-guard, repeat-tool-guard | Every spawn names a model and flows down the capability graph; fan-outs declare a ceiling; a run of single-statement calls or an identical repeated call is denied |
| Scope and integrity | scope-lock, gate-freeze, guard-canary.ps1, mods-liveness, forced-verify-stop-gate | Edits stay inside the claimed scope, frozen guard files match their lock, the guards are proven alive at session start, the Mods heartbeat is checked, a turn cannot end without its verification |
| Prompt chain | prompt-dispatch, wakeup-guard | The ONE UserPromptSubmit process: runs every prompt hook in-process and merges their answers (eight spawns per prompt timed out on a loaded machine); a session woken three times in a row by a Monitor or task notification is told to stop answering "Waiting." and stop the stalled task |
| Context | context-graph, declick-nudge, opus-handoff-inject, codex-memory-inject | Load the modules the prompt, file or command calls for, under a token budget, with the reason attached |
| Session state | session-count, creds-resolve, correction-tracker, precompact-extract, compaction-ledger, post-edit-diagnostics, skill-telemetry.py, sync-main-checkout.py | Count live sessions, fill .env from the local vault, record corrections, carry state across compaction, syntax-check edited files, record skill use |
| Governance | dashclaw-guard, dashclaw-setup | Optional: hold risky calls for remote approval in DashClaw |
| Client adapters | adapters/codex-rewrite, adapters/codex-delegate-guard, universal-adapter | Translate Codex payloads to the Claude dialect and back; declare what each client's runtime can do |
Every guard has an override marker for the case it was not written for, and a probe under engine/hooks/tests/ that makes it fail on purpose. The roster with markers: docs/guards.md. Measured cost per event: docs/hook-latency.md.
core/rules/global-rules.md is the working agreement every client receives: non-negotiables (secrets, hard stops), how to work, communication, definition of done, memory. npm run sync compiles it (with core/traits/traits.md) into the primary client's rules file; npm run port carries it everywhere.
Situational text is a module, not standing prompt. A module is a markdown file with a context: block (keyword, path and command triggers; requires and suggests edges) that loads when the prompt, the edited file or the command says it applies, in dependency order, under a budget, with the reason attached.
| Module | Loads when |
|---|---|
delegation-and-model-routing | An agent is about to be spawned: the model ladder, escalations, fan-out arithmetic |
memory-writing-rules | A prompt or a write touches memory: provenance tags, the recurrence gate, supersession |
secrets-non-negotiable | A command or edit names an env file, a key or a token |
harness-integrity | A hook or settings.json changes: probe, freeze, docs, doctor green |
parallel-agents-inbox | Another agent shares the repository or the inbox holds a claim |
harness-push-destinations | A commit is about to leave: which repository, which mirror |
seo-floor | A public web surface is created |
How the graph selects, resolves and packs: docs/context-graph.md. Your own section of CLAUDE.md comes from overlay/profile.md and never enters this repository.
engine/mods/ holds the function-hook plugins that run inside Claude Code's hooks engine with no process spawn: claude-runtime (the runtime adapter: events, judgments, snapshots) and harness-mods (routing, context nudges, secret redaction, subagent accounting, a read cache). canary.cjs and shadow-report.cjs verify them from a classic hook, and universal-adapter.cjs records which of these capabilities each other client has, so a port drops a Mods-only artefact with a reason instead of pretending. The research record behind them is labs/claude-mods/.
| Agent | Model | Role |
|---|---|---|
haiku-scout | haiku | Mechanical lookups: file and symbol hunts, inventories, git history; cites file and line |
sonnet-implementer | sonnet | A feature slice or refactor within a given scope; never edits test files |
opus-owner | opus | A large or risky task end to end; may delegate to the two above |
e2e-verifier | sonnet | Runs the verify command for a change someone else made; reports, never fixes |
security-reviewer | opus | Read-only review of auth, billing, secrets, webhooks and database changes |
advisor | one rung above the caller | One focused decision when an architecture choice or a second failed fix needs a stronger model |
| Workflow | Shape |
|---|---|
adversarial-review | Read-only finders per dimension, then a skeptic per finding that defaults to refuted |
fix-findings | Apply confirmed findings in disjoint ownership groups, review each fix, re-fix once, verify, converge |
tournament | N angled candidates, a judge panel scoring five criteria, one synthesized spec from the winner plus grafts |
understand | Parallel readers over named subsystems, one synthesized map answering a question |
Every spawn names its model and every fan-out declares its ceiling; the guards enforce it. The contract: core/rules/modules/delegation-and-model-routing.md.
Zero-dependency Node and PowerShell, each in its own directory with a README or a header that says what it does.
| Tool | What it answers |
|---|---|
hook-latency | Why a session is slow or token-hungry: wall clock per event, per hook, fixed context and cache losses per transcript, RAM and spawn latency |
bash-noprofile | A Git Bash shim for Claude Code's Bash tool on Windows that skips the login profile (a tool call went from 31.6 s to 4.2 s on the machine that motivated it) |
spend | Tokens and estimated dollars per day, model and session, from local transcripts, with rolling windows |
fleet | Which sessions are active, what each is working on, how heavy each is |
recall | One search across project memory, decision docs and the archive |
memstale | Every absolute path a memory mentions still exists |
memory-lint | Machine checks for markdown memory stores, run by the pre-commit hook |
skillfind | Find any skill on the machine, including the invisible ones (stale plugins, project-scoped, disabled versions) |
skill-telemetry | Skill usage over time, from transcripts and the Stop hook |
gates | The mechanical doc checks: word budgets, markdown links, the reference ratchet, note format, rule expiry, the guard freeze |
prove | Breaks the thing a check watches, confirms the check goes red, restores it, confirms green |
wiredark | Catches a new export with no production caller before it is committed |
task-contract | Validates a TASK_CONTRACT.md and can discharge every test-tier check |
envdoctor | Secrets and env wiring by name and presence only, never values |
gitradar | Every git repository on the machine: dirty, unpushed, gone branches, last commit age |
cronwatch | A health board for scheduled jobs that fail silently overnight |
harness-health | Every settings layer scanned, every hook command optionally probed once |
sync | The parity matrix per client and component, with check and port-now buttons |
dashboard | The command center: rules, skills, decisions, the error explorer, maintenance routines, in a browser |
errorlog | Two daily logs: what the agent predicted wrong, and what the human got wrong |
subagent-budget | Fits the subagent cost constants to measured transcripts |
deskclaw | A Windows desktop eye and hand: UI trees, screenshots, click, type, key, with redaction and denylists |
agent-browser | Detects and clears a wedged browser daemon |
ears | Local transcription, loudness and silence, waveform and spectrogram |
mouth | Short spoken status lines through Windows text-to-speech |
jobs/install.cjs --apply points Windows Task Scheduler at this checkout. The jobs are the harness's memory of its own mistakes:
| Job | When | What |
|---|---|---|
errorlog/harvest.sh | 06:22 daily | Extracts deviations and wrong assumptions from the day's transcripts |
meditation/run-nightly.sh | 06:40 daily | A headless session runs the reflection skill over the fresh error log with no credentials in its environment; a stronger model on Sundays for the weekly synthesis |
daily-distill.ps1 | nightly | engine/distill clusters the errors, evaluates candidate rules on a promotion ladder (observation, fact, rule, trait) and writes a proposal for a human to accept in the dashboard |
port-daily.ps1 | after the meditation | Captures the primary client and ports it, so every client wakes up with the promoted rules |
reapers/ | periodic | Kill orphaned agent processes and orphaned language servers |
engine/harvest feeds storage/candidates.jsonl; engine/distill promotes on evidence (three signals across two sessions, older signals counting half, a contradiction demoting); tools/dashboard is where a human approves. The same ladder produced most of the rules in core/rules/global-rules.md.
| Component | From (Claude Code) | To Codex CLI | To Gemini CLI | To Cursor | To 16 others |
|---|---|---|---|---|---|
Rules (CLAUDE.md, imports inlined) | ✓ | AGENTS.md | GEMINI.md | rules/*.mdc | each client's rules file |
Identity (SOUL.md) | ✓ | inlined | inlined | inlined | inlined or traits file |
Hooks (settings.json) | ✓ | config.toml, pre-trusted | settings.json via shim | hooks.json via shim | where the client has hooks |
Skills (skills/*/SKILL.md) | ✓ | linked | linked | linked | linked |
Subagents (agents/*.md) | ✓ | agents/*.toml, model ladder mapped | – | agents/*.md | where supported |
Slash commands (commands/*.md) | ✓ | prompts/*.md | commands/*.toml | commands/*.md | where supported |
MCP servers (.claude.json) | ✓ | [mcp_servers] | mcpServers | mcp.json | where supported |
| Permissions | ✓ | rules/*.rules | – | – | – |
Codex CLI works as the source too: the same eight components are read back from ~/.codex and written into Claude Code and the rest. The live matrix for your machine is npm run status; the generated per-client table is docs/targets.md: rules written to 20 of 20 clients, hooks driven in 5, skills linked in 15, subagents in 4, commands in 6, MCP in 8.
Supported clients: Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Cursor, Windsurf, GitHub Copilot, Cline, Aider, OpenHands, Goose, Continue, Zed, OpenCode, Trae, Amazon Q, Sourcegraph Cody, OpenClaw, Hermes, and a generic system prompt for any local or API model. Adding one is one entry in core/templates/targets.json (engine/harness/README.md).
npm run doctor answers one question: is the installed harness the one this repository describes? Fourteen checks, each printing the count it processed:
links, hook-wiring, rules-drift, claude-md, old-refs, private-boundary, linked-leftovers, orphan-hooks, unexpected-links, context-graph, data-files, deps, jobs, mirror-current.
The pre-commit hook runs the secrets scan, wiredark, the guard freeze and the doc gates on every commit. tools/prove exists because a check that was never observed failing has been run, not verified.
This repository holds everything portable: rules and modules, guards, Mods, agents, workflows, tools, jobs, docs. Your overlay (~/.claude/overlay/, in a repository of your own) holds profile.md (the private section of CLAUDE.md), context-graph.json (your context roots) and gates.json (extra frozen files); memory, meditations and settings.json stay in the Claude home. CLAUDE.md has one writer, npm run sync. The doctor fails when a tracked file here carries your account's home path. docs/private-overlay.md.
| Command | What |
|---|---|
npm run setup | First install: wire the core guards, link the surfaces, assemble CLAUDE.md, port, doctor. |
npm run sync / npm run sync:check | Compile the rules for the primary client, bind the links, assemble CLAUDE.md. Idempotent. |
npm run port / npm run port:check | Capture the primary client and apply to every other installed client; check writes nothing, exit 1 on drift. |
npm run doctor | Fourteen checks with counts: links, hook wiring, generated drift, second writers, retired references, private paths, data files, orphans, context graph, deps, scheduled jobs, the mirror. |
npm run status / npm run status:open | Per-client, per-component matrix, in the terminal or as a page. |
npm run explain | Everything that was not ported, with reasons. |
npm run distill | Run the promotion ladder over the harvested candidates and write the proposal. |
npm run dashboard | The command center in a browser. |
node jobs/install.cjs --apply | Re-point the scheduled jobs (Windows Task Scheduler) at this checkout. |
npm test | Engine suite plus sync, hook, wire-protocol, port, capability and context regressions. |
Flags: --from claude|codex, --to codex,gemini, --check, --dry-run, --force, --home <dir>, --json. Full list: docs/configuration.md.
| docs/where-things-go.md | One implementation per capability, one writer per generated file: where every kind of change goes. |
| docs/architecture.md | The three places, the dependency direction, capture and apply, storage. |
| docs/ownership.md | The ownership matrix: every capability, its one implementation, its writer, what generates from it. |
| docs/private-overlay.md | What the engine reads from your overlay and what stays private. |
| docs/guards.md, docs/hook-latency.md | Every guard hook and its override marker; what each event costs, measured. |
| docs/context-graph.md | Context modules: triggers, edges, budgets, the ledger. |
| docs/porting.md, docs/parity.md, docs/targets.md | How each component maps per client, verification, the generated per-client table. |
| docs/configuration.md | core/port.json, every command and flag, env vars, scheduled jobs, uninstall. |
| docs/doc-standard.md, docs/decision-notes.md | How prose is placed, sized and kept honest; how a decision is recorded. |
| docs/DECISIONS.md, docs/ERRORS.md | Durable decisions, newest first; what broke, why, and the lesson. |
| docs/windows-gotchas.md, docs/token-cache-discipline.md, docs/plugin-hygiene.md | Platform failures that are not code; why the cache ratio matters; why a disabled plugin is not a stopped one. |
| [docs/migration-2026-09.md](docs/
hooks/index.tsx 360 lines1// harness-mods — the harness's Function Hooks layer (hook glue only; policy lives in ./lib/*.mjs).
2//
3// Claude Code → claude-runtime (adapter, loaded first) → runtime.emit bus + $.runtime.* → THIS
4//
5// Raw events hooked here only where the Mod must intercept (agent.spawn, tool.call, prompt.submit);
6// everything observational comes off the bus. Every guard runs in the mode mods-config.json gives it
7// (classic | shadow_mod | mod); the classic hook for the same decision stands down only when the
8// heartbeat this file writes says the Mod armed that guard for this session (see lib/modes.mjs).
9// A silent failure cannot read as healthy: lib/canary.mjs grades the status this file records, the
10// heartbeat is re-written every main turn, and the classic canary leg reads it from outside.
11
12import { parseConfig, allModes, GUARDS, GUARD_NOTES } from "./lib/modes.mjs";
13import { explain, withContext, explainRoute } from "./lib/explain.mjs";
14import { observation, mutations, targetFor } from "./lib/targets.mjs";
15import { redactToolResult, summarize } from "./lib/redact.mjs";
16import { decide, signatureOf, EXIT_TOOLS } from "./lib/routing.mjs";
17import * as budget from "./lib/budget.mjs";
18import { row as shadowRow, report as shadowReport } from "./lib/shadow.mjs";
19import { summary as usageSummary } from "./lib/usage.mjs";
20import { newStatus, judge, RUNTIME_PIN } from "./lib/canary.mjs";
21
22const VERSION = "0.1.0";
23// The installed Claude home (CLAUDE_CONFIG_DIR, else ~/.claude): runtime state, the mode config and the
24// classic hooks' ledger all live there, whatever directory this plugin is loaded from. The hooks worker
25// has no Node globals; the home is read through `$.env` on the first event and cached (2026-09-19, after
26// a top-level environment read kept the whole module from loading).
27let CLAUDE_HOME = "";
28let STATE_DIR = "", CONFIG_PATH = "", CLASSIC_BUDGET_LOG = "";
29async function resolveHome($) {
30 if (CLAUDE_HOME) return;
31 let home = "";
32 try { home = (await $.env.get("CLAUDE_CONFIG_DIR")) || ""; } catch (err) { home = ""; }
33 if (!home) {
34 let base = "";
35 try { base = (await $.env.get("USERPROFILE")) || ""; } catch (err) { base = ""; }
36 if (!base) { try { base = (await $.env.get("HOME")) || ""; } catch (err) { base = ""; } }
37 home = base ? base + "/.claude" : "";
38 }
39 if (!home) return; // no home known: every path stays empty and the file writes below fail quietly
40 CLAUDE_HOME = String(home).replace(/\\/g, "/").replace(/\/$/, "");
41 STATE_DIR = CLAUDE_HOME + "/mods/state/";
42 CONFIG_PATH = CLAUDE_HOME + "/mods/mods-config.json";
43 CLASSIC_BUDGET_LOG = CLAUDE_HOME + "/hooks/.subagent-budget-log.jsonl";
44}
45const STORE_KEY = "harness-mods.ledger";
46const SERVE_AFTER = 3; // the Nth exact identical observation of an unchanged file is served from cache
47
48const state = {
49 sessionId: "", cfg: null, modes: {}, armed: {}, status: newStatus(), ledger: budget.newLedger(),
50 usage: null, model: "",
51 routes: {}, // tool_use_id -> route (for the Agent tool.call explanation)
52 fableUsed: 0, denied: {},
53 readCache: {}, // "tool|path|args" -> { result, chars, size, mtimeMs, n, firstAt }
54 observed: {}, // path -> count of observations across mechanisms
55 shadow: [], // rows this session (mod side)
56 redactions: 0, served: 0, savedChars: 0, calls: 0, lastRoute: null, lastLine: "",
57 dirty: false, hbTs: 0,
58};
59
60// ---------------------------------------------------------------- helpers with `$` (top-level, per the validator)
61async function readConfig($) { try { return parseConfig(await $.fs.read(CONFIG_PATH)); } catch (err) { return parseConfig(""); } }
62// Per-session overrides (the same names hooks/lib/mods-mode.cjs honours): $.env.get takes literal names only.
63async function readEnv($) {
64 const env = {};
65 try { env.HARNESS_MODS = await $.env.get("HARNESS_MODS"); } catch (err) {}
66 try { env.HARNESS_MOD_ROUTING = await $.env.get("HARNESS_MOD_ROUTING"); } catch (err) {}
67 try { env.HARNESS_MOD_SECRET_REDACTION = await $.env.get("HARNESS_MOD_SECRET_REDACTION"); } catch (err) {}
68 try { env.HARNESS_MOD_SUBAGENT_ACCOUNTING = await $.env.get("HARNESS_MOD_SUBAGENT_ACCOUNTING"); } catch (err) {}
69 try { env.HARNESS_MOD_READ_CACHE = await $.env.get("HARNESS_MOD_READ_CACHE"); } catch (err) {}
70 for (const k of Object.keys(env)) if (env[k] === undefined || env[k] === null) delete env[k];
71 return env;
72}
73async function appendLine($, path, obj) {
74 let prev = "";
75 try { if (await $.fs.exists(path)) prev = await $.fs.read(path); } catch (err) { prev = ""; }
76 try { await $.fs.write(path, prev + JSON.stringify(obj) + "\n"); } catch (err) {}
77}
78// /clear rotates the session id without a second session.start (observed 2026-09-18, session
79// b3802eef: heartbeat kept landing under the pre-clear id 962fcf8c, the classic witness and the
80// statusline looked up the new id and reported "did not load"). Re-read the id before every write.
81async function syncSessionId($) {
82 let sid = ""; try { sid = String((await $.session.id()) || ""); } catch (err) { return false; }
83 if (!sid || sid === state.sessionId) return false;
84 log($, "session id rotated " + state.sessionId.slice(0, 8) + " -> " + sid.slice(0, 8) + " (/clear); heartbeat re-keyed");
85 state.sessionId = sid; return true;
86}
87
88async function writeHeartbeat($, extra) {
89 await syncSessionId($);
90 const verdict = judge(state.status, state.modes, state.status.toolCalls > 0 ? "live" : "start");
91 const row = {
92 sessionId: state.sessionId, ts: Date.now(), plugin: "harness-mods", version: VERSION, pin: RUNTIME_PIN,
93 runtime: state.status.runtimeNoun, modes: state.modes, armed: state.armed, canary: verdict,
94 status: state.status, usage: state.usage ? { context: state.usage.context, rateLimits: state.usage.rateLimits, cost: state.usage.cost } : null,
95 counts: { calls: state.calls, redactions: state.redactions, served: state.served, savedChars: state.savedChars, spawns: state.ledger.totals.spawns, rewrites: state.ledger.totals.rewrites, denies: state.ledger.totals.denies, shadowRows: state.shadow.length },
96 ...extra,
97 };
98 state.hbTs = row.ts;
99 try { await $.fs.write(STATE_DIR + "sessions/" + state.sessionId + ".json", JSON.stringify(row)); } catch (err) {}
100 try { await $.fs.write(STATE_DIR + "canary-status.json", JSON.stringify({ sessionId: state.sessionId, ts: row.ts, ok: verdict.ok, failures: verdict.failures, enforcing: verdict.enforcing, modes: state.modes, version: VERSION, pin: RUNTIME_PIN })); } catch (err) {}
101}
102async function flushShadow($) {
103 if (!state.dirty) return;
104 state.dirty = false;
105 try { await $.fs.write(STATE_DIR + "shadow/" + state.sessionId + ".mod.jsonl", state.shadow.map((r) => JSON.stringify(r)).join("\n") + (state.shadow.length ? "\n" : "")); } catch (err) {}
106}
107async function probeAll($, e) {
108 try { state.status.supports = await $.runtime.supports(); state.status.runtimeNoun = !!state.status.supports; } catch (err) { state.status.runtimeNoun = false; }
109 try { const u = await $.session.usage(); state.usage = u; state.status.usageProbe = !!(u && u.context && typeof u.context.window === "number"); } catch (err) { state.status.usageProbe = false; }
110 try { const stored = await $.store.get(STORE_KEY); if (stored && stored.priors) state.ledger.priors = stored.priors; await $.store.set(STORE_KEY + ".probe", Date.now()); state.status.storeProbe = true; } catch (err) { state.status.storeProbe = false; }
111 try { state.model = await $.session.model(); } catch (err) { state.model = ""; }
112 state.status.version = RUNTIME_PIN.claudeVersion;
113}
114async function persistPriors($) { try { await $.store.set(STORE_KEY, { priors: state.ledger.priors, updatedAt: Date.now(), sessionId: state.sessionId }); } catch (err) {} }
115async function refreshUsage($) { try { state.usage = await $.session.usage(); return state.usage; } catch (err) { return null; } }
116async function statUnchanged($, path, entry) {
117 try { const st = await $.fs.stat(path); return !!st && st.kind === "file" && st.size === entry.size && st.mtimeMs === entry.mtimeMs; } catch (err) { return false; }
118}
119async function statOf($, path) { try { const st = await $.fs.stat(path); return st && st.kind === "file" ? { size: st.size, mtimeMs: st.mtimeMs } : null; } catch (err) { return null; } }
120function log($, line) { state.lastLine = line; $.ui.log("⟦mods⟧ " + line); }
121
122function mode(guard) { return state.modes[guard] || "classic"; }
123function enforcing(guard) { return mode(guard) === "mod" && state.armed[guard] === true; }
124function shadow(fields) {
125 state.shadow.push(shadowRow({ session: state.sessionId, side: "mod", ...fields }));
126 if (state.shadow.length > 5000) state.shadow.shift();
127 state.dirty = true;
128}
129function armedFor(modes, st) {
130 return {
131 routing: modes.routing === "mod",
132 secretRedaction: modes.secretRedaction === "mod",
133 subagentAccounting: modes.subagentAccounting === "mod" && st.runtimeNoun,
134 readCache: modes.readCache === "mod",
135 };
136}
137function bandText() {
138 const m = state.modes;
139 const short = { classic: "c", shadow_mod: "s", mod: "M" };
140 const abbr = { routing: "rou", secretRedaction: "sec", subagentAccounting: "sub", readCache: "rea" };
141 const guards = Object.keys(GUARDS).map((g) => (abbr[g] || g.slice(0, 3)) + ":" + (short[m[g]] || "?") + (m[g] === "mod" && !state.armed[g] ? "!" : "")).join(" ");
142 const c = state.status.runtimeNoun ? (judge(state.status, state.modes, state.status.toolCalls > 0 ? "live" : "start").ok ? "✓" : "✗") : "✗";
143 const t = budget.totals(state.ledger);
144 return "MODS " + c + " " + guards + " · " + usageSummary(state.usage) + " · spawns " + state.ledger.totals.spawns + " rw " + state.ledger.totals.rewrites + " · redact " + state.redactions + " · served " + state.served + " · live " + budget.liveAgents(state.ledger).length + " · reserved " + Math.round(t.reserved / 1000) + "k/charged " + Math.round(t.charged / 1000) + "k";
145}
146
147export const register = (on, options) => {
148 // ---------------------------------------------------------------- session
149 on("session.start", async ($, e, next) => {
150 await resolveHome($); // the validator wants $ handed only to top-level functions, so each hook resolves the home itself
151 state.sessionId = await $.session.id();
152 state.cfg = await readConfig($);
153 state.modes = allModes(state.cfg, await readEnv($));
154 await probeAll($, e);
155 state.armed = armedFor(state.modes, state.status);
156 await $.command.register({ name: "mods", description: "harness-mods: modes, canary, shadow comparison, ledger, rollback (status | shadow | ledger | rollback)", argumentHint: "[status|shadow|ledger|rollback]", immediate: true });
157 await writeHeartbeat($, { surface: e.surface, isInteractive: e.isInteractive });
158 const v = judge(state.status, state.modes, "start");
159 log($, "armed v" + VERSION + " · runtime=" + state.status.runtimeNoun + " · modes " + Object.keys(state.modes).map((g) => g + "=" + state.modes[g]).join(" ") + " · canary " + (v.ok ? "ok" : "FAIL " + v.failures.join("; ")));
160 // No $.ui.status band and no AbovePrompt tree (removed 2026-09-18): the statusline already
161 // shows the same modes, usage and cost from the heartbeat, and three copies of one line is noise.
162 // The band lives on in /mods and in the heartbeat file.
163 return next(e);
164 }).catch(($, e, next) => { state.status.hookErrors++; state.status.lastError = "session.start"; return next(e); });
165
166 // ---------------------------------------------------------------- the bus (observation only)
167 on("runtime.emit", async ($, e, next) => {
168 await resolveHome($); // the validator wants $ handed only to top-level functions, so each hook resolves the home itself
169 const r = await next(e);
170 state.status.busEvents++;
171 const d = e.data || {};
172 if (e.kind === "ModelStep" && e.agentId) budget.step(state.ledger, e.agentId, d.usage, Date.now());
173 else if (e.kind === "SubagentCompleted" && e.agentId) {
174 const a = budget.settle(state.ledger, e.agentId, { usage: d.usage, durationMs: d.durationMs, reason: d.reason }, Date.now());
175 if (a) {
176 const nowIso = new Date().toISOString();
177 const crow = budget.calibrationRow(state.sessionId, a, nowIso);
178 await appendLine($, STATE_DIR + "subagents.jsonl", { ...crow, resolvedModel: a.resolvedModel || null, parentModel: a.parentModel || null, reasons: a.reasons || [] });
179 if (mode("subagentAccounting") !== "classic") await appendLine($, CLASSIC_BUDGET_LOG, crow);
180 await persistPriors($);
181 shadow({ subsystem: "subagentAccounting", action: "settle " + a.type, key: a.agentId, mode: mode("subagentAccounting"), decision: "measured", requestedValue: a.declared, resolvedValue: a.measured, reasonCodes: [a.reservedFrom], enforced: false, actualOutcome: a.reason, note: "predictionError=" + a.predictionError + " reserveError=" + a.reserveError });
182 log($, "settle " + String(a.agentId).slice(0, 8) + " " + a.type + " " + a.model + " calls=" + a.calls + " declared=" + (a.declared === null ? "n/a" : a.declared) + " reserved=" + a.reserved + " measured=" + a.measured + " overhead=" + (a.overhead || 0) + " · median(" + a.type + ")=" + a.learnedPrior + " overhead-median=" + a.learnedOverhead);
183 }
184 } else if (e.kind === "UsageChanged") {
185 state.usage = d;
186 } else if (e.kind === "ToolCompleted" && !state.status.order && Array.isArray(d.trace)) {
187 state.status.order = d.trace.filter((t) => t.tier === "user").map((t) => t.plugin);
188 }
189 return r;
190 }).catch(($, e, next) => { state.status.hookErrors++; state.status.lastError = "runtime.emit"; return next(e); });
191
192 // ---------------------------------------------------------------- routing (the one raw agent.spawn hook)
193 on("agent.spawn", async ($, e, next) => {
194 await resolveHome($); // the validator wants $ handed only to top-level functions, so each hook resolves the home itself
195 const t0 = Date.now();
196 const type = e.subagentType || "general-purpose";
197 const sig = signatureOf(state.sessionId, type, e.prompt, e.model);
198 const prior = budget.priorFor(state.ledger, type);
199 const d = decide(e, { fableUsed: state.fableUsed, fableCap: 3, prior: prior.tokens, priorSource: prior.source, isWarm: budget.isWarm(state.ledger, t0), deniedOnce: !!state.denied[sig] });
200 const m = mode("routing");
201 const enforce = enforcing("routing");
202 const route = { ms: t0, type, parent: e.parentModel, requested: e.model, model: d.model, action: d.action, reasons: d.reasons, est: d.budget ? d.budget.est : null, agentId: null, resolvedModel: null, enforced: enforce, tool_use_id: e.tool_use_id };
203 state.ledger.totals.spawns++;
204 state.status.enforcementReached.agentSpawn = true;
205
206 if (d.action === "deny" && enforce) {
207 state.ledger.totals.denies++;
208 state.denied[sig] = 1;
209 state.lastRoute = route;
210 shadow({ subsystem: "routing", action: "Agent " + type, key: sig, mode: m, decision: "deny", requestedValue: e.model || null, resolvedValue: null, reasonCodes: d.reasons, enforced: true, latencyMs: Date.now() - t0 });
211 log($, "DENY " + type + " parent=" + e.parentModel + " [" + d.reasons.join(", ") + "]");
212 await flushShadow($);
213 return { deny: d.reason };
214 }
215 const wouldRewrite = d.action === "rewrite";
216 const sent = enforce && wouldRewrite ? { ...e, model: d.model } : e;
217 if (enforce && wouldRewrite) state.ledger.totals.rewrites++; else state.ledger.totals.passes++;
218 if (d.graph && d.graph.fableCounted) state.fableUsed++;
219 const r = await next(sent);
220 const agentId = r && r.agentId ? r.agentId : null;
221 route.agentId = agentId; route.resolvedModel = r ? r.model : null;
222 state.lastRoute = route;
223 if (e.tool_use_id) state.routes[e.tool_use_id] = route;
224 if (agentId) budget.open(state.ledger, { agentId, type, model: r.model, resolvedModel: r.model, requested: e.model, rewritten: enforce && wouldRewrite, reasons: d.reasons, est: route.est, declared: d.budget && d.budget.est ? prior.tokens + d.budget.est.calls * 2000 + d.budget.est.files * 2000 : null, reserved: prior.total, reservedFrom: prior.totalSource, parentModel: e.parentModel, description: String(e.description || "").slice(0, 60) }, Date.now());
225 shadow({ subsystem: "routing", action: "Agent " + type, key: sig, mode: m, decision: d.action === "deny" ? "deny" : wouldRewrite ? "rewrite" : "allow", requestedValue: e.model || null, resolvedValue: r ? r.model : null, reasonCodes: d.reasons, wouldRewrite, enforced: enforce, latencyMs: Date.now() - t0, actualOutcome: r && r.deny ? "denied-beneath: " + String(r.deny).slice(0, 80) : agentId ? "spawned" : "no-agent" });
226 log($, (enforce ? (wouldRewrite ? "REWRITE " : d.action === "deny" ? "WOULD-DENY(anti-thrash) " : "PASS ") : "SHADOW(" + d.action + ") ") + type + " " + (e.model === undefined ? "inherit" : e.model) + " → " + (r ? r.model : "?") + " (parent " + e.parentModel + ") [" + d.reasons.join(", ") + "]" + (agentId ? " agent=" + String(agentId).slice(0, 8) + " reserve=" + prior.total + " overhead=" + prior.tokens : ""));
227 await flushShadow($);
228 return r;
229 }).catch(($, e, next) => { state.status.hookErrors++; state.status.lastError = "agent.spawn"; return next(e); });
230
231 // ---------------------------------------------------------------- tool.call (the one raw hook: exit exemption, read cache, redaction, explanations)
232 on("tool.call", async ($, e, next) => {
233 await resolveHome($); // the validator wants $ handed only to top-level functions, so each hook resolves the home itself
234 const t0 = Date.now();
235 state.calls++; state.status.toolCalls++; state.status.enforcementReached.toolCall = true;
236 const { tool, tool_use_id, agentId, ...input } = e;
237 if (agentId) { const a = state.ledger.agents[agentId]; if (a) a.calls++; }
238 if (EXIT_TOOLS.indexOf(tool) >= 0) return next(e); // an exit tool is never intercepted, cached or redacted
239
240 // 1. mutations invalidate cached observations
241 const muts = mutations(tool, input);
242 if (muts.length) {
243 for (const k of Object.keys(state.readCache)) { if (muts.indexOf("*") >= 0 || muts.indexOf(state.readCache[k].path) >= 0) delete state.readCache[k]; }
244 for (const p of muts) if (p !== "*") state.observed[p] = 0;
245 if (muts.indexOf("*") >= 0) state.observed = {};
246 }
247
248 // 2. the read cache: target-keyed detection, strict serving
249 const obs = observation(tool, input);
250 let cacheKey = null;
251 if (obs) {
252 state.observed[obs.path] = (state.observed[obs.path] || 0) + 1;
253 const n = state.observed[obs.path];
254 cacheKey = tool + "|" + obs.path + "|" + (tool === "Read" ? String(input.offset) + "|" + String(input.limit) : String(input.command || "").trim());
255 const entry = state.readCache[cacheKey];
256 if (n >= 2) shadow({ subsystem: "readCache", action: obs.via + " " + obs.path, key: obs.path + "#" + n, mode: mode("readCache"), decision: entry && n >= SERVE_AFTER && obs.exact ? "serve" : "repeat", requestedValue: obs.via, resolvedValue: n, reasonCodes: [entry ? "cached" : "not-cached", obs.exact ? "exact" : "inexact"], wouldRewrite: !!(entry && n >= SERVE_AFTER && obs.exact), enforced: enforcing("readCache") });
257 if (entry && n >= SERVE_AFTER && obs.exact && enforcing("readCache") && (await statUnchanged($, obs.path, entry))) {
258 entry.n++;
259 state.served++; state.savedChars += entry.chars;
260 const note = explain({ kind: "cache-serve", what: obs.via + " of " + obs.path + " (observation #" + n + ")", why: "identical call, file unchanged since observation #" + entry.firstAt + " (size+mtime verified)", actual: "this is the cached result of that earlier call, not a fresh read; saved ~" + Math.ceil(entry.chars / 4) + " tokens" });
261 log($, "SERVED " + obs.via + " " + obs.path + " #" + n + " (~" + Math.ceil(entry.chars / 4) + " tok)");
262 await flushShadow($);
263 return withContext({ result: entry.result }, note);
264 }
265 }
266
267 // 3. run it
268 let r = await next(e);
269 const latency = Date.now() - t0;
270
271 // 4. redaction: detection becomes replacement
272 const red = redactToolResult(r);
273 if (red) {
274 const m = mode("secretRedaction");
275 const enforce = enforcing("secretRedaction");
276 shadow({ subsystem: "secretRedaction", action: tool + " " + String(targetFor(tool, input) || ""), key: tool_use_id, mode: m, decision: enforce ? "redact" : "detect", resolvedValue: red.kinds.join(","), reasonCodes: red.kinds, wouldRewrite: true, enforced: enforce, latencyMs: latency, note: summarize(red.hits) });
277 await appendLine($, STATE_DIR + "redactions.jsonl", { ts: new Date().toISOString(), session: state.sessionId, tool, tool_use_id, agentId: agentId || null, kinds: red.kinds, hits: red.hits.length, enforced: enforce });
278 if (enforce) {
279 state.redactions++;
280 const note = explain({ kind: "redaction", what: red.hits.length + " secret shape(s) [" + summarize(red.hits) + "] replaced with <REDACTED:…> in the " + tool + " result", why: "a credential must never enter the transcript", actual: "the tool ran normally; only the secret values were masked. Do not try to reprint them; tell the user which credential it was so they can rotate it if it was real" });
281 log($, "REDACTED " + summarize(red.hits) + " in " + tool + " result");
282 $.ui.toast("harness-mods redacted " + summarize(red.hits) + " from a " + tool + " result");
283 // A fresh answer: never spread `r` here, its `text`/`ref` still carry the raw value and the
284 // adapter above logs `text` (seen 2026-09-16: the value reached the event log through `text`).
285 r = withContext({ result: red.result, isError: r.isError === true ? true : undefined }, note);
286 } else {
287 log($, "SHADOW would redact " + summarize(red.hits) + " in " + tool + " result (classic watch alerts)");
288 }
289 }
290
291 // 5. cache the first exact observation
292 if (obs && obs.exact && cacheKey && !state.readCache[cacheKey] && r && !r.deny && !r.isError && r.result !== undefined) {
293 const st = await statOf($, obs.path);
294 if (st) { let chars = 0; try { chars = JSON.stringify(r.result).length; } catch (err) { chars = 0; } state.readCache[cacheKey] = { path: obs.path, result: r.result, chars, size: st.size, mtimeMs: st.mtimeMs, n: 1, firstAt: state.observed[obs.path] }; }
295 }
296
297 // 6. the Agent tool's result: explain a model rewrite to the parent (agent.spawn's result carries no context)
298 if (tool === "Agent" && tool_use_id && state.routes[tool_use_id]) {
299 const note = explainRoute(state.routes[tool_use_id]);
300 if (note && state.routes[tool_use_id].enforced) r = withContext(r, note);
301 }
302 return r;
303 }).catch(($, e, next) => { state.status.hookErrors++; state.status.lastError = "tool.call"; return next(e); });
304
305 // ---------------------------------------------------------------- prompt.submit: session heartbeat
306 on("prompt.submit", async ($, e, next) => {
307 await resolveHome($); // the validator wants $ handed only to top-level functions, so each hook resolves the home itself
308 if (await syncSessionId($)) await writeHeartbeat($, {});
309 return next(e);
310 }).catch(($, e, next) => { state.status.hookErrors++; state.status.lastError = "prompt.submit"; return next(e); });
311
312 // ---------------------------------------------------------------- turn.complete: heartbeat, usage, flush
313 on("turn.complete", async ($, e, next) => {
314 await resolveHome($); // the validator wants $ handed only to top-level functions, so each hook resolves the home itself
315 if (!e.agentId) {
316 await refreshUsage($);
317 await flushShadow($);
318 await writeHeartbeat($, {});
319 }
320 return next(e);
321 }).catch(($, e, next) => { state.status.hookErrors++; state.status.lastError = "turn.complete"; return next(e); });
322
323 // ---------------------------------------------------------------- /mods
324 on("command.run", { command: "mods" }, async ($, e, next) => {
325 await resolveHome($); // the validator wants $ handed only to top-level functions, so each hook resolves the home itself
326 await flushShadow($);
327 const arg = (e.args || "").trim().toLowerCase() || "status";
328 const v = judge(state.status, state.modes, state.status.toolCalls > 0 ? "live" : "start");
329 const head = "HARNESS MODS v" + VERSION + " · session " + state.sessionId + " · model " + state.model
330 + "\n canary: " + (v.ok ? "OK" : "FAIL") + (v.failures.length ? "\n " + v.failures.join("\n ") : "")
331 + "\n modes: " + Object.keys(state.modes).map((g) => g + "=" + state.modes[g] + (state.modes[g] === "mod" ? (state.armed[g] ? " (armed)" : " (NOT ARMED → classic enforcing)") : "")).join(", ")
332 + "\n runtime noun " + state.status.runtimeNoun + " · bus events " + state.status.busEvents + " · tool calls " + state.status.toolCalls + " · order " + (state.status.order ? state.status.order.join(" > ") : "n/a") + " · hook errors " + state.status.hookErrors
333 + "\n usage: " + usageSummary(state.usage)
334 + "\n band: " + bandText()
335 + "\n counts: spawns " + state.ledger.totals.spawns + " rewritten " + state.ledger.totals.rewrites + " denied " + state.ledger.totals.denies + " · redactions " + state.redactions + " · served " + state.served + " · shadow rows " + state.shadow.length;
336 if (arg === "rollback") {
337 return { text: head + "\n\nROLLBACK (any one of these; classic hooks are still installed and take over immediately):"
338 + "\n per guard, all sessions: edit " + CONFIG_PATH + " → \"<guard>\": \"classic\""
339 + "\n per session: HARNESS_MOD_ROUTING=classic (or HARNESS_MODS=off) in the environment"
340 + "\n whole layer: claude plugin disable harness-mods@harness-mods (and claude-runtime@harness-mods)"
341 + "\n the classic side yields only while sessions/<id>.json says the Mod armed the guard; delete that file and classic enforces." };
342 }
343 if (arg === "shadow") {
344 const rep = shadowReport(state.shadow);
345 return { text: head + "\n\nSHADOW (this session, mod side only; join with state/shadow/<id>.classic.jsonl for pairs)\n" + JSON.stringify(rep, null, 1).slice(0, 6000) };
346 }
347 if (arg === "ledger") {
348 const rows = state.ledger.order.map((id) => state.ledger.agents[id]).map((a) => " " + String(a.agentId).slice(0, 8) + " " + String(a.type).padEnd(20) + String(a.model).padEnd(26) + " calls=" + a.calls + " declared=" + (a.declared === null ? "n/a" : a.declared) + " reserved=" + a.reserved + " (" + a.reservedFrom + ") " + (a.settled ? "measured=" + a.measured + " err=" + a.predictionError : "live tok=" + a.tokens));
349 const priors = Object.keys(state.ledger.priors).map((t) => " " + t.padEnd(22) + "n=" + state.ledger.priors[t].samples.length + " median=" + state.ledger.priors[t].median);
350 return { text: head + "\n\nLEDGER\n" + (rows.join("\n") || " none") + "\n\nPRIORS ($.store " + STORE_KEY + ")\n" + (priors.join("\n") || " none yet") };
351 }
352 const notes = GUARD_NOTES.map(([g, note]) => " " + g + " [" + mode(g) + "]: " + note).join("\n");
353 return { text: head + "\n last: " + state.lastLine + "\n files: " + STATE_DIR + "{sessions/<id>.json, shadow/, subagents.jsonl, redactions.jsonl, canary-status.json}\n\nGUARDS\n" + notes };
354 });
355
356 // No ui.render AbovePrompt band (removed 2026-09-18): the statusline reads the heartbeat this file
357 // writes and draws the modes once, below the prompt. Route decisions still reach the transcript
358 // through log() and the /mods status output.
359};
360hooks/lib/modes.mjs 89 lines1// modes.mjs — the migration configuration model, pure.
2//
3// Every migrated guard has exactly one owner per session:
4// classic the existing hook enforces; the Mod observes only
5// shadow_mod the existing hook enforces; the Mod computes what it WOULD do and records the comparison
6// mod the Mod enforces; the classic hook stands down (it stays installed for rollback)
7//
8// The classic side and the Mod side read the SAME file (~/.claude/mods/mods-config.json), and the
9// classic side only stands down when the Mod has proven itself alive for THIS session (heartbeat),
10// so a Mod that failed to load can never leave a decision unowned.
11//
12// Per-session override: env HARNESS_MOD_<GUARD>=classic|shadow_mod|mod (GUARD upper-cased,
13// e.g. HARNESS_MOD_ROUTING=classic). HARNESS_MODS=off forces every guard to classic.
14
15export const MODES = ["classic", "shadow_mod", "mod"];
16
17// The migrated guards. Descriptions live in a parallel list (not `name: "text"` pairs) so the
18// mirror's secret sweep, which flags `secret…: "…"` shapes, never trips on the redaction entry.
19export const GUARDS = {
20 routing: 1,
21 secretRedaction: 1,
22 subagentAccounting: 1,
23 readCache: 1,
24};
25export const GUARD_NOTES = [
26 ["routing", "agent-model-guard (Agent/Task branch) + capability-graph-guard (PreToolUse) + subagent-budget-guard: model rewrite onto the capability graph, advisor escalation, Fable cap, measured budget"],
27 ["secretRedaction", "tool-output-secret-watch.cjs: tool results with credential shapes are masked before the model sees them (the classic watch stays as the after-the-fact backstop)"],
28 ["subagentAccounting", "subagent-budget-guard --post + calibrate.cjs: measured per-agent usage, declared-vs-measured, learned priors"],
29 ["readCache", "costclaw-live: the Nth identical observation of an unchanged file is served from cache (target-keyed)"],
30];
31
32export const DEFAULT_CONFIG = {
33 version: 1,
34 updatedAt: null,
35 guards: {
36 routing: "shadow_mod",
37 secretRedaction: "shadow_mod",
38 subagentAccounting: "shadow_mod",
39 readCache: "shadow_mod",
40 },
41};
42
43export function normalizeMode(v, fallback = "classic") {
44 const s = String(v || "").toLowerCase().trim();
45 return MODES.includes(s) ? s : fallback;
46}
47
48/** Resolve the effective mode of one guard from the config object + an env map. Pure. */
49export function modeFor(guard, config, env = {}) {
50 if (String(env.HARNESS_MODS || "").toLowerCase() === "off") return "classic";
51 const key = "HARNESS_MOD_" + String(guard).replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase();
52 if (env[key]) return normalizeMode(env[key]);
53 const g = config && config.guards ? config.guards[guard] : undefined;
54 const dflt = DEFAULT_CONFIG.guards[guard] || "classic";
55 return normalizeMode(g, dflt);
56}
57
58/** Every guard's effective mode. */
59export function allModes(config, env = {}) {
60 const out = {};
61 for (const g of Object.keys(GUARDS)) out[g] = modeFor(g, config, env);
62 return out;
63}
64
65/** Parse a config file's text; a broken file means classic everywhere (fail safe, never fail open). */
66export function parseConfig(text) {
67 try {
68 const j = JSON.parse(text);
69 if (!j || typeof j !== "object" || !j.guards || typeof j.guards !== "object") return { ...DEFAULT_CONFIG, guards: {}, broken: true };
70 return j;
71 } catch (err) {
72 return { ...DEFAULT_CONFIG, guards: {}, broken: true };
73 }
74}
75
76/**
77 * Does the classic side stand down for this guard? Only when the mode is `mod` AND the heartbeat
78 * says the Mod armed that guard in this session. `heartbeat` is the parsed sessions/<id>.json or null.
79 */
80// WIRE-DARK[the classic side implements this same rule in ~/.claude/hooks/lib/mods-mode.cjs; this mirror exists so tests/lib.test.mjs proves the two agree]
81export function classicStandsDown(guard, config, env, heartbeat, nowMs = Date.now(), maxAgeMs = 6 * 3600 * 1000) {
82 if (modeFor(guard, config, env) !== "mod") return { standDown: false, why: "mode is not mod" };
83 if (!heartbeat || typeof heartbeat !== "object") return { standDown: false, why: "no heartbeat for this session" };
84 if (!heartbeat.armed || heartbeat.armed[guard] !== true) return { standDown: false, why: "Mod did not arm " + guard };
85 const age = nowMs - (Number(heartbeat.ts) || 0);
86 if (!(age >= 0 && age <= maxAgeMs)) return { standDown: false, why: "heartbeat stale (" + Math.round(age / 60000) + " min)" };
87 return { standDown: true, why: "mod armed " + guard + " for this session" };
88}
89hooks/lib/explain.mjs 60 lines1// explain.mjs — one reusable way to tell the model what a Mod changed. Pure.
2//
3// Why: the 2026-09-16 dogfood (DOGFOOD.md findings 2) showed the model reasoning from false beliefs
4// after silent rewrites: it "requested opus" when it actually got haiku, and reported a command as
5// "blocked" when it had been contained. Whenever a Mod materially changes what the model asked for,
6// the result carries one concise hidden `context[]` line saying WHAT changed, WHY, and WHAT ACTUALLY
7// HAPPENED. It is information, never instruction (R20): enforcement lives in the hook, not the note.
8
9export const KINDS = {
10 "model-rewrite": "subagent model rewritten",
11 "command-containment": "command contained (dry-run)",
12 "tool-substitution": "tool substituted",
13 "result-replacement": "tool result replaced",
14 "cache-serve": "result served from cache",
15 "arg-normalization": "tool arguments normalized",
16 "redaction": "secret redacted from result",
17};
18
19const MAX = 320;
20
21function clip(s, n) {
22 s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
23 return s.length > n ? s.slice(0, n - 1) + "…" : s;
24}
25
26/**
27 * Build the one-line explanation.
28 * kind one of KINDS (unknown kinds are kept verbatim)
29 * what the change, in the model's terms ("model opus -> haiku")
30 * why the reason codes or a short reason ("upward edge not in the capability graph")
31 * actual what really happened ("the subagent ran on haiku and its answer below is real")
32 * by the Mod that did it (default "harness-mods")
33 */
34export function explain({ kind, what, why, actual, by = "harness-mods" }) {
35 const head = KINDS[kind] || String(kind || "changed");
36 const parts = ["[" + by + "] " + head + ": " + clip(what, 120)];
37 if (why) parts.push("why: " + clip(Array.isArray(why) ? why.join(", ") : why, 110));
38 if (actual) parts.push("actual: " + clip(actual, 120));
39 return clip(parts.join(" · "), MAX);
40}
41
42/** Attach an explanation to a tool.call / agent.spawn style result without clobbering existing context. */
43export function withContext(result, note) {
44 if (!note) return result;
45 const r = result && typeof result === "object" ? result : {};
46 const prev = Array.isArray(r.context) ? r.context : [];
47 return { ...r, context: [...prev, note] };
48}
49
50/** The explanation for a routing decision (used by routing.mjs consumers). */
51export function explainRoute(route) {
52 if (!route || route.action !== "rewrite") return null;
53 return explain({
54 kind: "model-rewrite",
55 what: (route.type || "agent") + " model " + (route.requested === undefined ? "(inherit)" : route.requested) + " -> " + route.model,
56 why: route.reasons,
57 actual: "the subagent ran on " + route.model + (route.resolvedModel ? " (" + route.resolvedModel + ")" : "") + "; its result is real",
58 });
59}
60hooks/lib/targets.mjs 91 lines1// targets.mjs — the (tool, target) grain, shared with CostClaw. Pure.
2//
3// commandTarget / targetFor are ported verbatim from costclaw packages/engine/src/parser.ts:65-100
4// (CostClaw now exports them: see costclaw packages/engine/src/targets.ts). Keep the two in
5// lockstep; the drift test in tests/targets.test.mjs compares against the CostClaw copy when present.
6//
7// observation() is the addition the 2026-09-16 dogfood asked for (DOGFOOD.md finding 1): a logical
8// file observation may happen through Read, Grep, Glob, or a safe shell inspection (cat, head, tail,
9// wc, type, Get-Content). It resolves the FILE the call observes, so a repeated-read rule keys on
10// the file, not the tool name. Serving from cache stays stricter (see readcache in index.tsx).
11
12export function commandTarget(command) {
13 let s = String(command || "").trim();
14 const hop = /^(?:cd|Set-Location|pushd)\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/i;
15 const env = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+/;
16 for (;;) {
17 const next2 = s.replace(hop, "").replace(env, "");
18 if (next2 === s) break;
19 s = next2;
20 }
21 const words = s.split(/\s+/).filter(Boolean);
22 if (words.length === 0) return null;
23 return words.slice(0, 2).join(" ").slice(0, 60);
24}
25
26export function targetFor(name, input) {
27 const s = (v, n) => (typeof v === "string" && v.length ? v.slice(0, n) : null);
28 input = input || {};
29 if (["Read", "Edit", "Write", "NotebookEdit"].includes(name)) return s(input.file_path || input.notebook_path, 200);
30 if (["Grep", "Glob"].includes(name)) return s(input.path || input.glob || input.pattern, 160);
31 if (["Bash", "PowerShell"].includes(name)) return typeof input.command === "string" ? commandTarget(input.command) : null;
32 if (String(name).indexOf("Task") === 0) return s(input.subject || input.description, 80);
33 if (["WebFetch", "WebSearch"].includes(name)) return s(input.url || input.query, 60);
34 if (name === "Agent") return s(input.subagent_type || input.description, 60);
35 return null;
36}
37
38// ---- file observations across mechanisms ---------------------------------------------------
39
40const INSPECT_VERBS = new Set(["cat", "head", "tail", "wc", "type", "less", "more", "get-content", "gc"]);
41// One simple command: no pipes, no redirects, no chaining, no substitution. Flags allowed only in
42// the numeric/short forms these verbs take. Anything else is not a safe inspection.
43const SIMPLE = /^(?:cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*)?([A-Za-z-]+)((?:\s+-{1,2}[A-Za-z]+(?:\s+\d+|=\d+|\d+)?)*)\s+("[^"]+"|'[^']+'|[^\s"'|&;<>$`]+)\s*(?:#.*)?$/;
44
45function normPath(p) {
46 return String(p || "").replace(/^["']|["']$/g, "").replace(/\\/g, "/");
47}
48
49/**
50 * Resolve the file a call OBSERVES (reads without changing), or null.
51 * Returns { path, via, exact } where `exact` is true when the observation is a whole-file read
52 * whose result would be byte-identical to a repeat (Read with no offset/limit; plain `cat file`).
53 */
54export function observation(tool, input) {
55 input = input || {};
56 if (tool === "Read" && typeof input.file_path === "string") {
57 return { path: normPath(input.file_path), via: "Read", exact: input.offset === undefined && input.limit === undefined };
58 }
59 if ((tool === "Grep" || tool === "Glob") && typeof input.path === "string") {
60 return { path: normPath(input.path), via: tool, exact: false };
61 }
62 if ((tool === "Bash" || tool === "PowerShell") && typeof input.command === "string") {
63 const m = SIMPLE.exec(input.command.trim());
64 if (!m) return null;
65 const verb = m[1].toLowerCase();
66 if (!INSPECT_VERBS.has(verb)) return null;
67 const flags = (m[2] || "").trim();
68 const path = normPath(m[3]);
69 if (!path || path.startsWith("-")) return null;
70 return { path, via: tool + " " + verb, exact: verb === "cat" && flags === "" };
71 }
72 return null;
73}
74
75/** Paths a call MUTATES (invalidates any cached observation of them). */
76export function mutations(tool, input) {
77 input = input || {};
78 if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool)) {
79 const p = input.file_path || input.notebook_path;
80 return p ? [normPath(p)] : [];
81 }
82 if ((tool === "Bash" || tool === "PowerShell") && typeof input.command === "string") {
83 // Shell writes cannot be resolved to exact paths safely; report the mutation as "unknown" so the
84 // cache is flushed whole. A plain inspection command is not a mutation.
85 if (observation(tool, input)) return [];
86 if (/(^|[^&|<>])>>?|\bsed\s+-\w*i|\btee\b|\b(?:Set-Content|Out-File|Add-Content|Copy-Item|Move-Item|Remove-Item|Rename-Item|New-Item)\b|\b(?:cp|mv|rm|truncate|touch|mkdir|rmdir|git\s+(?:checkout|reset|stash|pull|merge|rebase|apply|clean)|npm\s+(?:i|install|ci)|pip\s+install)\b/i.test(input.command)) return ["*"];
87 return [];
88 }
89 return [];
90}
91hooks/lib/redact.mjs 76 lines1// redact.mjs — detection becomes redaction. Pure.
2//
3// Patterns are the harness's mature vendor-prefixed shapes, shared with output-secret-watch.cjs and
4// tool-output-secret-watch.cjs so the three channels cannot drift apart. The two classic watches
5// DETECT after the fact ("by the time PostToolUse fires the result is already in the transcript");
6// this module produces the replacement text a `tool.call` hook returns INSTEAD, so the model never
7// receives the value. Never returns or logs a secret: hits carry kind, head (8 chars) and length only.
8
9import patterns from "./secret-patterns.mjs";
10
11export const PATTERNS = patterns.PATTERNS;
12export const PLACEHOLDER = patterns.PLACEHOLDER;
13
14export function mask(kind, len) {
15 return "<REDACTED:" + kind + ":" + len + ">";
16}
17
18/** Redact one string. Returns { text, hits } (hits: [{kind, head, len}]). */
19export function redactText(text) {
20 if (typeof text !== "string" || !text) return { text, hits: [] };
21 const hits = [];
22 let out = text;
23 for (const [kind, re] of PATTERNS) {
24 const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
25 out = out.replace(g, (m) => {
26 if (PLACEHOLDER.test(m)) return m;
27 hits.push({ kind, head: m.slice(0, 8) + "…", len: m.length });
28 return mask(kind, m.length);
29 });
30 }
31 return { text: out, hits };
32}
33
34/** Deep-redact every string inside a tool result record (Bash {stdout,stderr}, Read {content…}, MCP content blocks…). */
35export function redactValue(value, depth = 0) {
36 if (depth > 12) return { value, hits: [] };
37 if (typeof value === "string") {
38 const r = redactText(value);
39 return { value: r.text, hits: r.hits };
40 }
41 if (Array.isArray(value)) {
42 const hits = [];
43 const out = value.map((v) => { const r = redactValue(v, depth + 1); hits.push(...r.hits); return r.value; });
44 return { value: out, hits };
45 }
46 if (value && typeof value === "object") {
47 const hits = [];
48 const out = {};
49 for (const k of Object.keys(value)) { const r = redactValue(value[k], depth + 1); hits.push(...r.hits); out[k] = r.value; }
50 return { value: out, hits };
51 }
52 return { value, hits: [] };
53}
54
55/**
56 * Redact a tool.call result. `r` is what next(e) resolved to: { result, text?, isError?, context? }.
57 * Returns null when nothing matched (return the original), else the replacement result object plus hits.
58 * The engine re-maps the model-facing text from `result` with the tool's own mapper.
59 */
60export function redactToolResult(r) {
61 if (!r || r.deny !== undefined) return null;
62 const res = redactValue(r.result);
63 const txt = typeof r.text === "string" ? redactText(r.text) : { text: r.text, hits: [] };
64 const hits = [...res.hits, ...txt.hits];
65 if (!hits.length) return null;
66 const kinds = [...new Set(hits.map((h) => h.kind))];
67 return { result: res.value, hits, kinds };
68}
69
70/** A summary line safe to log or show: never the value. */
71export function summarize(hits) {
72 const byKind = {};
73 for (const h of hits) byKind[h.kind] = (byKind[h.kind] || 0) + 1;
74 return Object.keys(byKind).map((k) => k + "×" + byKind[k]).join(", ");
75}
76hooks/lib/routing.mjs 161 lines1// routing.mjs — the supervisor's routing policy as one pure function over the agent.spawn event.
2//
3// Replaces, in `mod` mode, the Agent/Task branch of agent-model-guard.cjs (explicit model + Fable
4// cap), capability-graph-guard.cjs's PreToolUse verdict (downward-only graph, advisor one rung up,
5// Haiku is a leaf) and subagent-budget-guard.cjs's pre-spawn check (# EST: break-even against a
6// PRIOR the ledger learns). Rewrite over deny: a violation is corrected onto the graph and explained
7// to the model; deny is reserved for what no rewrite fixes (a Haiku parent, an undeclared scope
8// below break-even on its first attempt). Reason codes are stable strings for the audit log.
9
10export const RUNG_NAME = ["?", "haiku", "sonnet", "opus", "fable"];
11export const ADVISOR_TYPES = ["advisor"];
12export const FORK_TYPES = ["fork"];
13export const DEFAULT_FABLE_CAP = 3;
14// Subagent types whose definition pins a non-Fable model; safe without an explicit model (agent-model-guard).
15export const PINNED_SAFE_SUBAGENTS = ["codex:codex-rescue"];
16// Never gated by the budget check (subagent-budget-guard EXEMPT_TYPES).
17export const BUDGET_EXEMPT_TYPES = ["advisor", "statusline-setup"];
18// Tools a subagent needs to EXIT; a per-agent cap must never deny these (DOGFOOD finding 4: denying
19// SubagentHandback produced four retries of the exit tool). Audit 2026-09-16 of claude-code.d.ts:
20// SubagentHandback is the only exit tool named; TaskStop/TaskOutput are parent-side. Keep the list
21// data so a new build's exit tool is a one-line change.
22export const EXIT_TOOLS = ["SubagentHandback"];
23
24export function rungOf(model) {
25 if (!model) return 0;
26 const m = String(model).toLowerCase();
27 if (m.indexOf("fable") >= 0 || m.indexOf("mythos") >= 0) return 4;
28 if (m.indexOf("opus") >= 0) return 3;
29 if (m.indexOf("sonnet") >= 0) return 2;
30 if (m.indexOf("haiku") >= 0) return 1;
31 return 0;
32}
33
34export function parseEst(text) {
35 const tag = /#\s*EST\s*:?\s*([^\n]*)/i.exec(String(text || ""));
36 if (!tag) return null;
37 const body = tag[1];
38 const calls = /(\d+)\s*(?:tool\s*)?calls?/i.exec(body);
39 const files = /(\d+)\s*files?/i.exec(body);
40 if (!calls && !files) return null;
41 return { calls: calls ? parseInt(calls[1], 10) : 0, files: files ? parseInt(files[1], 10) : 0, raw: body.trim() };
42}
43
44export function spawnOk(text) {
45 const m = /#\s*SPAWN_OK\s*:\s*(\S[^\n]*)/i.exec(String(text || ""));
46 return m ? m[1].trim() : null;
47}
48
49/**
50 * The capability-graph half. Pure over the event.
51 * Returns { action: "pass"|"rewrite"|"deny", model, reason, reasons[], parentRung, reqRung, targetRung, fableCounted }.
52 */
53export function decideGraph(e, ctx) {
54 const fableUsed = (ctx && ctx.fableUsed) || 0;
55 const fableCap = ctx && typeof ctx.fableCap === "number" ? ctx.fableCap : DEFAULT_FABLE_CAP;
56 const reasons = [];
57 const type = e.subagentType || "general-purpose";
58 const requested = e.model;
59 const reqRung = rungOf(requested);
60 let parentRung = rungOf(e.parentModel);
61 const base = { model: requested, reason: "", reasons, parentRung, reqRung, fableCounted: false };
62
63 if (PINNED_SAFE_SUBAGENTS.indexOf(String(type).toLowerCase()) >= 0) {
64 reasons.push("pinned-safe-subagent");
65 return { ...base, action: "pass", targetRung: reqRung };
66 }
67 if (e.fork || FORK_TYPES.indexOf(type) >= 0) {
68 reasons.push("fork-inherits-parent", "model-field-ignored-by-engine");
69 if (parentRung === 4) { reasons.push("fork-counts-against-fable-cap"); base.fableCounted = true; }
70 return { ...base, action: "pass", targetRung: parentRung };
71 }
72 if (parentRung === 0) { reasons.push("parent-model-unrecognised-assumed-opus"); parentRung = 3; base.parentRung = 3; }
73 if (parentRung === 1) {
74 reasons.push("haiku-is-a-leaf", "no-rewrite-can-fix-a-leaf");
75 return { ...base, action: "deny", targetRung: 0, reason: "harness-mods routing: Haiku spawns nobody (capability graph: Haiku -> nobody). Do the work in this agent or report back to your caller." };
76 }
77 if (ADVISOR_TYPES.indexOf(type) >= 0) {
78 const target = Math.min(4, parentRung + 1);
79 reasons.push("advisor-upward-edge", "advisor-uncapped");
80 if (!reqRung) reasons.push(requested ? "model-unrecognised" : "model-undeclared");
81 else if (reqRung !== target) reasons.push("advisor-model-overridden");
82 reasons.push("set-one-rung-above-parent");
83 return { ...base, action: reqRung === target ? "pass" : "rewrite", model: RUNG_NAME[target], targetRung: target };
84 }
85
86 let target = reqRung;
87 if (!reqRung) {
88 reasons.push(requested ? "model-unrecognised" : "model-undeclared");
89 target = parentRung - 1;
90 }
91 if (target === 4 && fableUsed >= fableCap) {
92 reasons.push("fable-cap-" + fableCap + "-per-session");
93 target = 3;
94 }
95 if (target >= parentRung) {
96 reasons.push(target === parentRung ? "peer-edge-not-in-graph" : "upward-edge-not-in-graph", "downgraded-to-highest-child-rung");
97 target = parentRung - 1;
98 }
99 if (target === reqRung && reqRung > 0) reasons.push("edge-in-graph");
100 if (target === 4) base.fableCounted = true;
101 return { ...base, action: target === reqRung ? "pass" : "rewrite", model: RUNG_NAME[target], targetRung: target };
102}
103
104/**
105 * The budget half (subagent-budget-guard arithmetic with a learned prior).
106 * prior tokens the spawn costs before its first tool call (measured median or the shipped constant)
107 * isWarm a subagent request happened inside the warm window
108 * deniedOnce this exact signature was already denied this session (anti-thrash)
109 * Returns { action: "pass"|"deny", reasons[], est, declared, inline, spawn, breakEvenCalls, reason }.
110 */
111export const BUDGET = { RESULT_TOKENS: 2000, FILE_TOKENS: 2000, TURNS_REMAINING: 20, CACHE_DISCOUNT: 0.1, WARM_DISCOUNT: 0.25 };
112
113export function decideBudget(e, ctx) {
114 const reasons = [];
115 const type = String(e.subagentType || "general-purpose");
116 const prompt = String(e.prompt || "");
117 if (BUDGET_EXEMPT_TYPES.indexOf(type.toLowerCase()) >= 0) { reasons.push("budget-exempt-type"); return { action: "pass", reasons, est: null }; }
118 const ok = spawnOk(prompt);
119 const est = parseEst(prompt);
120 const overhead = Math.round((ctx.prior || 0) * (ctx.isWarm ? BUDGET.WARM_DISCOUNT : 1));
121 const amplification = 1 + BUDGET.TURNS_REMAINING * BUDGET.CACHE_DISCOUNT;
122 const breakEvenCalls = Math.ceil(overhead / (BUDGET.RESULT_TOKENS * amplification - BUDGET.RESULT_TOKENS));
123 if (ok) { reasons.push("spawn-ok-override"); return { action: "pass", reasons, est, why: ok, overhead, breakEvenCalls }; }
124 if (!est) {
125 if (ctx.deniedOnce) { reasons.push("no-est-tag", "anti-thrash-second-attempt"); return { action: "pass", reasons, est: null, overhead, breakEvenCalls }; }
126 reasons.push("no-est-tag");
127 return {
128 action: "deny", reasons, est: null, overhead, breakEvenCalls,
129 reason: "BLOCKED: this Agent dispatch declares no scope, so nothing checked it against the cost of doing the work inline. A \"" + type + "\" spawn costs ~" + Math.round(overhead / 1000) + "k tokens before its first tool call (" + (ctx.priorSource || "prior") + "); break-even is about " + breakEvenCalls + " tool calls. Add `# EST: <n> calls, <n> files` to the prompt and re-issue, or `# SPAWN_OK: <why>` if the scope genuinely cannot be known up front.",
130 };
131 }
132 const spawn = Math.round(overhead + est.calls * BUDGET.RESULT_TOKENS);
133 const inline = Math.round((est.calls * BUDGET.RESULT_TOKENS + est.files * BUDGET.FILE_TOKENS) * amplification);
134 if (spawn <= inline) { reasons.push("declared-scope-clears-break-even"); return { action: "pass", reasons, est, spawn, inline, overhead, breakEvenCalls }; }
135 if (ctx.deniedOnce) { reasons.push("below-break-even", "anti-thrash-second-attempt"); return { action: "pass", reasons, est, spawn, inline, overhead, breakEvenCalls }; }
136 reasons.push("below-break-even");
137 return {
138 action: "deny", reasons, est, spawn, inline, overhead, breakEvenCalls,
139 reason: "BLOCKED: the declared scope (" + est.raw + ") does not pay for a spawn. Spawning \"" + type + "\": ~" + overhead.toLocaleString() + " overhead (" + (ctx.priorSource || "prior") + ") + " + est.calls + " calls × " + BUDGET.RESULT_TOKENS.toLocaleString() + " = ~" + spawn.toLocaleString() + " tokens; inline: ~" + inline.toLocaleString() + " tokens. Do it directly, or re-issue with `# SPAWN_OK: <why>` if context isolation is the point.",
140 };
141}
142
143/** The whole routing verdict: graph first (it may deny), then budget. */
144export function decide(e, ctx) {
145 const graph = decideGraph(e, ctx);
146 if (graph.action === "deny") return { action: "deny", model: graph.model, reason: graph.reason, reasons: graph.reasons, graph, budget: null };
147 const budget = decideBudget(e, ctx);
148 if (budget.action === "deny") return { action: "deny", model: graph.model, reason: budget.reason, reasons: [...graph.reasons, ...budget.reasons], graph, budget };
149 return { action: graph.action, model: graph.model, reason: "", reasons: [...graph.reasons, ...budget.reasons], graph, budget };
150}
151
152/** Same-session, same-type, same-prompt signature for anti-thrash (matches subagent-budget-guard's). */
153export function signatureOf(sessionId, type, prompt, model) {
154 // FNV-1a 32-bit over the string; no crypto in a hooks module. The requested model is part of the
155 // key so two spawns of the same type+prompt with different models pair separately (seen 2026-09-16).
156 const s = String(sessionId) + " " + String(type || "") + " " + String(model || "") + " " + String(prompt || "");
157 let h = 0x811c9dc5;
158 for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
159 return h.toString(16).padStart(8, "0");
160}
161hooks/lib/budget.mjs 142 lines1// budget.mjs — measured subagent accounting. Pure state transitions over a ledger object.
2//
3// The classic guard DECLARED (# EST:) and could only log allow/deny; the runtime now gives per-agent
4// usage on every turn.step / turn.complete (agentId), so each spawn is priced before it runs
5// (reserved, from the learned prior) and settled after (measured), and the prior for that agent type
6// refits itself as a running median kept in $.store across sessions. Constants are inherited from
7// subagent-budget-guard (LEAN 17k / FULL 60k / cache reads weighted 0.1).
8//
9// Two priors per type, never one (2026-09-18): OVERHEAD is what the spawn costs before its first
10// tool call (the system prompt, the brief, the first model step) and is what the break-even rule in
11// routing.mjs prices; TOTAL is the whole run and is what the ledger reserves. Until this split the
12// learned median of the TOTAL was fed to the break-even rule as if it were overhead, so an opus-owner
13// with a 2M-token history priced at ~53 tool calls to break even and was denied on first attempt.
14
15export const LEAN_PRIOR = 17000;
16export const FULL_PRIOR = 60000;
17export const CACHE_DISCOUNT = 0.1;
18export const MAX_SAMPLES = 20;
19export const WARM_WINDOW_MS = 5 * 60 * 1000;
20// Bumped 2026-09-29: before it, the first step's usage was never captured (tool.call counts the call
21// before its ModelStep arrives), so every stored overhead sample was a whole-run total.
22export const OVERHEAD_VERSION = 2;
23export const LEAN_TYPES = ["haiku-scout", "sonnet-implementer", "opus-owner", "advisor", "e2e-verifier", "explore", "plan", "statusline-setup", "security-reviewer", "fork"];
24
25export function isLean(type) { return LEAN_TYPES.indexOf(String(type || "").toLowerCase()) >= 0; }
26
27export function medianOf(arr) {
28 const s = (arr || []).slice().sort((a, b) => a - b);
29 const n = s.length;
30 if (!n) return 0;
31 return n % 2 ? s[(n - 1) / 2] : Math.round((s[n / 2 - 1] + s[n / 2]) / 2);
32}
33
34export function weigh(u) {
35 if (!u) return 0;
36 return (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0) + Math.round(CACHE_DISCOUNT * (u.cache_read_input_tokens || 0));
37}
38
39export function newLedger() {
40 return { agents: {}, order: [], priors: {}, lastActivityMs: 0, totals: { spawns: 0, rewrites: 0, denies: 0, passes: 0, fable: 0 } };
41}
42
43/**
44 * `tokens`/`source`: the spawn OVERHEAD prior (tokens before the first tool call) for the break-even
45 * rule; `total`/`totalSource`: the whole-run prior the ledger reserves. A stored prior that only has
46 * total samples (pre-split sessions) prices overhead from the classic constant, never from the total.
47 * So does one whose overhead samples predate OVERHEAD_VERSION: those were whole-run totals.
48 */
49export function priorFor(ledger, type) {
50 const p = ledger.priors[type];
51 const classic = isLean(type) ? LEAN_PRIOR : FULL_PRIOR;
52 const classicSource = isLean(type) ? "prior lean" : "prior full";
53 const hasOverhead = !!(p && p.overheadV === OVERHEAD_VERSION && p.overhead > 0 && Array.isArray(p.overheads));
54 const hasTotal = !!(p && p.median > 0 && Array.isArray(p.samples));
55 return {
56 tokens: hasOverhead ? p.overhead : classic,
57 source: hasOverhead ? "learned overhead n=" + p.overheads.length : classicSource,
58 total: hasTotal ? p.median : classic,
59 totalSource: hasTotal ? "learned total n=" + p.samples.length : classicSource,
60 };
61}
62
63export function isWarm(ledger, nowMs) {
64 return ledger.lastActivityMs > 0 && nowMs - ledger.lastActivityMs < WARM_WINDOW_MS;
65}
66
67/** Open a ledger row after the spawn resolved. */
68export function open(ledger, row, nowMs) {
69 ledger.agents[row.agentId] = {
70 steps: 0, turns: 0, tokens: 0, measured: 0, durationMs: 0, calls: 0, settled: false, reason: "", startedMs: nowMs, ...row,
71 };
72 ledger.order.push(row.agentId);
73 ledger.lastActivityMs = nowMs;
74 return ledger.agents[row.agentId];
75}
76
77export function step(ledger, agentId, usage, nowMs) {
78 const a = ledger.agents[agentId];
79 if (!a) return null;
80 a.steps++;
81 a.tokens += weigh(usage);
82 // Everything spent before the first tool call is overhead. The first step always is: its tool call can be
83 // counted before this event arrives, so `!a.calls` alone never fired on a live run.
84 if (a.steps === 1 || !a.calls) a.overhead = a.tokens;
85 ledger.lastActivityMs = nowMs;
86 return a;
87}
88
89/** Settle on the subagent's turn.complete; refit the prior. Returns the settled row with its prediction error. */
90export function settle(ledger, agentId, ev, nowMs) {
91 const a = ledger.agents[agentId];
92 if (!a) return null;
93 a.turns++;
94 a.measured += weigh(ev.usage);
95 a.durationMs += ev.durationMs || 0;
96 a.settled = true;
97 a.reason = ev.reason || "";
98 a.endedMs = nowMs;
99 const p = ledger.priors[a.type] || { samples: [], median: 0 };
100 // A pre-split prior has no overhead samples; a pre-OVERHEAD_VERSION one has totals posing as overhead.
101 if (!Array.isArray(p.overheads) || p.overheadV !== OVERHEAD_VERSION) { p.overheads = []; p.overhead = 0; p.overheadV = OVERHEAD_VERSION; }
102 p.samples.push(a.measured);
103 if (p.samples.length > MAX_SAMPLES) p.samples.shift();
104 p.median = medianOf(p.samples);
105 // Only a captured step is an overhead sample. A run with no ModelStep seen contributes none, never its total.
106 const overhead = a.overhead > 0 ? a.overhead : 0;
107 if (overhead > 0) {
108 p.overheads.push(overhead);
109 if (p.overheads.length > MAX_SAMPLES) p.overheads.shift();
110 p.overhead = medianOf(p.overheads);
111 }
112 ledger.priors[a.type] = p;
113 ledger.lastActivityMs = nowMs;
114 a.predictionError = a.declared === null || a.declared === undefined ? null : a.measured - a.declared;
115 a.reserveError = a.measured - a.reserved;
116 a.learnedPrior = p.median;
117 a.learnedOverhead = p.overhead;
118 return a;
119}
120
121export function liveAgents(ledger) {
122 return ledger.order.map((id) => ledger.agents[id]).filter((a) => a && !a.settled);
123}
124
125export function totals(ledger) {
126 let reserved = 0, charged = 0;
127 for (const id of ledger.order) { const a = ledger.agents[id]; if (!a) continue; reserved += a.reserved || 0; charged += a.settled ? a.measured : a.reserved || 0; }
128 return { reserved, charged };
129}
130
131/** A row in the calibration log's vocabulary (subagent-budget-guard's .subagent-budget-log.jsonl). calibrate.cjs filters on decision allow|deny and ignores this. */
132export function calibrationRow(sessionId, a, nowIso) {
133 return {
134 ts: nowIso, session: sessionId, decision: "measured", source: "harness-mods",
135 type: a.type, model: a.model, requested: a.requested || null, rewritten: !!a.rewritten, agentId: a.agentId,
136 est: a.est || null, declared: a.declared, reserved: a.reserved, reservedFrom: a.reservedFrom,
137 measured: a.measured, predictionError: a.predictionError, reserveError: a.reserveError,
138 calls: a.calls, steps: a.steps, durationMs: a.durationMs, reason: a.reason, learnedPrior: a.learnedPrior,
139 overhead: a.overhead || 0, learnedOverhead: a.learnedOverhead || 0,
140 };
141}
142hooks/lib/shadow.mjs 67 lines1// shadow.mjs — the classic-vs-Mod comparison record. Pure.
2//
3// One row per decision, written by whichever side decided (classic writes via
4// ~/.claude/hooks/lib/mods-mode.cjs recordShadow; the Mod writes via index.tsx). Rows join on
5// (session, subsystem, action key). `report()` answers the Phase-19 questions from a row set.
6
7export function row(fields) {
8 return {
9 ts: fields.ts || new Date().toISOString(),
10 session: fields.session || null,
11 side: fields.side, // "classic" | "mod"
12 subsystem: fields.subsystem, // "routing" | "secretRedaction" | "readCache" | "subagentAccounting"
13 action: fields.action, // what was decided on ("Agent haiku-scout", "Bash …", "context 82%")
14 key: fields.key || null, // join key (signature / tool_use_id / path)
15 mode: fields.mode || null, // effective mode when the row was written
16 decision: fields.decision, // "allow" | "deny" | "rewrite" | "redact" | "serve" | "nudge" | "none"
17 requestedValue: fields.requestedValue === undefined ? null : fields.requestedValue,
18 resolvedValue: fields.resolvedValue === undefined ? null : fields.resolvedValue,
19 reasonCodes: fields.reasonCodes || [],
20 wouldRewrite: !!fields.wouldRewrite,
21 enforced: !!fields.enforced, // did this side actually enforce
22 latencyMs: fields.latencyMs === undefined ? null : fields.latencyMs,
23 retryCount: fields.retryCount === undefined ? null : fields.retryCount,
24 actualOutcome: fields.actualOutcome === undefined ? null : fields.actualOutcome,
25 note: fields.note || null,
26 };
27}
28
29/** Pair classic and mod rows by (session, subsystem, key) and grade agreement. */
30export function pair(rows) {
31 const by = {};
32 for (const r of rows) {
33 const k = [r.session, r.subsystem, r.key].join("|");
34 (by[k] = by[k] || { classic: [], mod: [] })[r.side === "classic" ? "classic" : "mod"].push(r);
35 }
36 const out = [];
37 for (const k of Object.keys(by)) {
38 const c = by[k].classic[0] || null, m = by[k].mod[0] || null;
39 const agreement = c && m ? (normalize(c.decision) === normalize(m.decision) ? "agree" : "disagree") : c ? "classic-only" : "mod-only";
40 out.push({ key: k, subsystem: (c || m).subsystem, classic: c, mod: m, agreement,
41 classicDecision: c ? c.decision : null, modDecision: m ? m.decision : null,
42 modWouldRewrite: m ? m.wouldRewrite : null, reasonCodes: m ? m.reasonCodes : c ? c.reasonCodes : [],
43 latencyMs: { classic: c ? c.latencyMs : null, mod: m ? m.latencyMs : null } });
44 }
45 return out;
46}
47
48// A classic "deny" and a Mod "rewrite" disagree in mechanism but agree on the policy violation.
49function normalize(d) { return d === "rewrite" ? "deny" : d; }
50
51export function report(rows) {
52 const pairs = pair(rows);
53 const bySub = {};
54 for (const p of pairs) {
55 const s = bySub[p.subsystem] = bySub[p.subsystem] || { pairs: 0, agree: 0, disagree: 0, classicOnly: 0, modOnly: 0, modRewrites: 0, classicDenies: 0, modDenies: 0, disagreements: [] };
56 s.pairs++;
57 if (p.agreement === "agree") s.agree++;
58 else if (p.agreement === "disagree") { s.disagree++; s.disagreements.push({ key: p.key, classic: p.classicDecision, mod: p.modDecision, reasons: p.reasonCodes }); }
59 else if (p.agreement === "classic-only") s.classicOnly++;
60 else s.modOnly++;
61 if (p.mod && p.mod.decision === "rewrite") s.modRewrites++;
62 if (p.classic && p.classic.decision === "deny") s.classicDenies++;
63 if (p.mod && p.mod.decision === "deny") s.modDenies++;
64 }
65 return { rows: rows.length, pairs: pairs.length, bySubsystem: bySub };
66}
67hooks/lib/usage.mjs 20 lines1// usage.mjs — native session usage ($.session.usage()) → the band's one-line summary.
2// The 80% context-budget nudge that used to live here was removed on 2026-09-26 (Wes).
3
4export function rateLimit(usage, kind) {
5 const r = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits.find((x) => x.kind === kind) : null;
6 return r ? Math.round(r.percentUsed) : null;
7}
8
9export function costUsd(usage) {
10 return usage && usage.cost && typeof usage.cost.usd === "number" ? usage.cost.usd : null;
11}
12
13/** One-line summary for the band / status file. */
14export function summary(usage) {
15 if (!usage) return "usage n/a";
16 const ctx = usage.context && typeof usage.context.percent === "number" ? Math.round(usage.context.percent) + "%" : "?";
17 const five = rateLimit(usage, "five_hour"), week = rateLimit(usage, "seven_day"), usd = costUsd(usage);
18 return "ctx " + ctx + " · 5h " + (five === null ? "?" : five + "%") + " · 7d " + (week === null ? "?" : week + "%") + " · " + (usd === null ? "$?" : "$" + usd.toFixed(2));
19}
20hooks/lib/canary.mjs 54 lines1// canary.mjs — what a healthy Mod layer looks like, so silence cannot read as health. Pure.
2//
3// The Mod fills a status object during the session (probes at session.start, counts as events flow,
4// the middleware order from the first tool.call trace). `judge()` turns it into ok/failures. The
5// classic canary leg (~/.claude/mods/canary.cjs, run by guard-canary.ps1 and the first-prompt
6// liveness hook) reads the written status and applies the same judgement from outside the plugin.
7
8// The order is read from the ADAPTER's next.trace (ToolCompleted.data.trace): it lists the user plugins
9// beneath the adapter, so seeing harness-mods there proves claude-runtime is outermost. The adapter
10// never appears in its own trace.
11export const EXPECTED_ORDER = ["harness-mods"];
12export const EXPECTED_SUPPORTS = ["toolInterception", "toolResultMutation", "runtimeEvents", "subagentEvents", "dynamicPermissions", "usageSignals", "contextSignals", "middleware", "runtimeMemory"];
13export const RUNTIME_PIN = { claudeVersion: "2.1.274", dtsSha256Prefix: "ab7a8a2d45f5d8d8" };
14
15export function newStatus() {
16 return {
17 runtimeNoun: false, // $.runtime reachable
18 supports: null, // probed capability set
19 usageProbe: false, // $.session.usage() returned a context window
20 storeProbe: false, // $.store round-trip worked
21 busEvents: 0, // runtime.emit events seen
22 toolCalls: 0, // raw tool.call events seen
23 order: null, // plugin order from next.trace on the first tool.call
24 hookErrors: 0, // .catch handlers hit
25 enforcementReached: { toolCall: false, agentSpawn: false },
26 version: null,
27 lastError: null,
28 };
29}
30
31/** Grade a status. `modes` are the effective guard modes; `phase` is "start" (probes only) or "live". */
32export function judge(status, modes, phase = "live") {
33 const failures = [];
34 if (!status.runtimeNoun) failures.push("adapter-noun-missing: $.runtime is not reachable (claude-runtime did not load or loaded after harness-mods)");
35 if (!status.supports) failures.push("capability-probe-missing");
36 else for (const k of EXPECTED_SUPPORTS) if (status.supports[k] !== true) failures.push("capability-changed: " + k + "=" + JSON.stringify(status.supports[k]));
37 if (!status.usageProbe) failures.push("session-usage-unavailable");
38 if (!status.storeProbe) failures.push("store-unavailable");
39 if (status.hookErrors > 0) failures.push("hook-failures: " + status.hookErrors + (status.lastError ? " (" + status.lastError + ")" : ""));
40 if (phase === "live") {
41 if (status.toolCalls > 0 && status.busEvents === 0) failures.push("events-not-flowing: tool calls seen but no runtime.emit events");
42 if (status.order && !sameOrder(status.order, EXPECTED_ORDER)) failures.push("middleware-order: adapter trace shows [" + status.order.join(" > ") + "], expected [" + EXPECTED_ORDER.join(" > ") + "] beneath claude-runtime (is claude-runtime loaded first?)");
43 if (status.toolCalls > 0 && !status.enforcementReached.toolCall) failures.push("enforcement-unreachable: tool.call never reached harness-mods");
44 }
45 const enforcing = Object.keys(modes || {}).filter((g) => modes[g] === "mod");
46 if (enforcing.length && failures.length) failures.push("mod-mode-with-failures: " + enforcing.join(",") + " are in mod mode while the layer is unhealthy; classic fallback must stay armed");
47 return { ok: failures.length === 0, failures, enforcing };
48}
49
50function sameOrder(actual, expected) {
51 const a = actual.filter((p) => expected.includes(p));
52 return a.length === expected.length && a.every((p, i) => p === expected[i]);
53}
54hooks/lib/secret-patterns.mjs 18 lines1// secret-patterns.mjs — GENERATED COPY of ~/.claude/hooks/lib/secret-patterns.cjs (a hooks module may
2// import only its own files). tests/redact.test.mjs fails when the two drift; regenerate with:
3// node engine/mods/harness-mods/tests/gen-patterns.cjs
4export const PATTERNS = [
5 ["anthropic", /\bsk-ant-[A-Za-z0-9_-]{24,}/g],
6 ["openai", /\bsk-(?:proj-)?[A-Za-z0-9]{32,}/g],
7 ["stripe-live", /\b(?:sk|rk)_live_[A-Za-z0-9]{20,}/g],
8 ["github-pat", /\bgh[pousr]_[A-Za-z0-9]{30,}/g],
9 ["aws-key-id", /\bAKIA[0-9A-Z]{16}\b/g],
10 ["slack-token", /\bxox[abprs]-[A-Za-z0-9-]{20,}/g],
11 ["google-api", /\bAIza[0-9A-Za-z_-]{35}\b/g],
12 ["private-key", /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g],
13 ["jwt", /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
14 ["neon-url", /\bpostgres(?:ql)?:\/\/[^\s:@/]+:[^\s:@/]{8,}@/g],
15];
16export const PLACEHOLDER = /(?:XXXX|xxxx|\.\.\.|<[^>]+>|\bYOUR_|\bEXAMPLE\b|\bPLACEHOLDER\b|\bREDACTED\b|A{12,}|0{12,}|1234567890)/;
17export default { PATTERNS, PLACEHOLDER };
18