Team memory for Claude Code. Your sessions are saved automatically, so past work is searchable later. Your team's rules show up while you work, at the moment…

MemHub gives coding agents shared team memory. This plugin connects Claude Code to MemHub by XTrace. It provides MCP tools for searching and saving team knowledge, and it captures your sessions into your personal MemHub memory automatically. It also enforces your team's Rulebook rules on tool calls, and adds skills for artifacts, specs, handoffs, PR linking and PR babysitting.
The plugin uploads data to MemHub in the background. The section What it runs, sends, and fetches lists every hook, every network destination and every local file. Please read it before you install.
XTrace publishes this plugin from two sources. Install it from one of them:
memhub on claude.ai under Customize > Plugins, or in Claude Code with /plugin install memhub@claude-plugins-official. The directory's copy is built from XTraceAI/xtrace-claude-plugin, and Claude Code updates it once each new version is published to the directory. New versions can reach the directory a little later than the marketplace below. /plugin marketplace add XTraceAI/agent-plugins
/plugin install memhub@memhub
/reload-plugins
Install it from one source only. Both copies register the same hooks and an MCP server named memhub, so with both enabled every session is captured twice and every team rule fires twice. To switch, uninstall the copy you have (/plugin uninstall memhub@memhub or /plugin uninstall memhub@claude-plugins-official), restart Claude Code, then install the other.
Then, from the repository you want to connect:
/memhub:onboard. It signs the plugin in (the same sign-in as /memhub:login, which background capture and the hooks need), sets up this machine's hooks, and creates or selects the repository's agent brain.Requirements: python3 3.9 or newer and git. The hooks and the skills' scripts use only the Python standard library, so nothing else is installed: skills run them as python3 "${CLAUDE_PLUGIN_ROOT}/scripts/<script>.py" or python3 "${CLAUDE_PLUGIN_ROOT}/skills/<skill>/scripts/<script>.py".
There are two credentials, and setting up one does not set up the other.
memhub server). The server is https://api.memhub.xtrace.ai/mcp-server/mcp. Its OAuth login uses the Auth0 tenant memhub-prod.us.auth0.com, with a browser redirect to localhost:8765. On Claude Code, the server's headersHelper (scripts/mcp_headers.py) sends the credential from /memhub:login (the stored access key, else the plugin's cached OAuth token) as the Authorization header. Before it does, it checks the credential against the server with one request per connect. So after /memhub:login the tools work without a separate /mcp login. If there is no credential, or the server refuses it, the helper prints no header, and Authenticate under /mcp applies./memhub:login). This opens the browser once for the same Auth0 login. It then mints a personal access key (mhk_…). The key is scoped to memory:read and memory:write, expires after 90 days, and is labelled claude-code-<hostname>. It is stored at ~/.config/memhub-plugin/pak-<api-host>.json. Hooks are background processes that cannot open a browser, so they rely on this key. When the login succeeds, the local callback page sends your browser on to MemHub's setup guide under https://mem.xtrace.ai/plugin.Optional harnessDrafting setting. A boolean userConfig option, on by default. Set it to false and the plugin drafts no team rule from your corrections: the Stop hook judges no turn and asks for no drafting fork, whatever MEMHUB_HARNESS_EXTRACT says. See Harness-tied rule drafting.
Optional memhub_token setting. The plugin declares a userConfig option, memhub_token. It is a masked field, it is not required, and it has no default. Claude Code keeps its value in the system credential store. Leave it empty to use the key from /memhub:login. Hooks receive it as CLAUDE_PLUGIN_OPTION_MEMHUB_TOKEN. Set it with /plugin configure memhub@<marketplace>, or claude plugin install memhub@<marketplace> --config memhub_token=mhk_….
Hooks use the first credential they find, in this order:
memhub_token option;$MEMHUB_TOKEN;~/.config/memhub-plugin/tokens-<api-host>.json), refreshed when stale.Neither the option nor $MEMHUB_TOKEN reaches the MCP tools, because Claude Code gives the headersHelper neither of them. Skill scripts that run through the Bash tool don't see the option either.
Each skill runs as /memhub:<name>, or when you ask for it in plain words.
| Skill | What it does, reads and sends |
|---|---|
login | Signs the plugin in and stores its access key (see Authentication). |
onboard | Signs the plugin in when it is not (as login), checks capture health, then creates or reuses the repository's agent brain and records it in rooms.json. Lists the repository's tracked Markdown files (git ls-files) and uploads the ones that look important to that brain without asking. It also saves a short repo overview it writes. With MEMHUB_HARNESS_EXTRACT=1 on Claude Code it adds CLAUDE_CODE_FORK_SUBAGENT=1 under env in ~/.claude/settings.json. On Codex it installs the Codex hooks bridge (see Codex and Cursor) and asks you to approve it in Codex. --status only reports; --remove takes those machine settings out. |
save-artifact | Uploads a file you name as an artifact, to the repository's brain when there is one, else to your personal memory. |
import-session | Reads a past Claude Code, Codex or Cursor transcript from this machine and uploads it to your personal memory, in chunks when it is large. |
search-memory | Read-only search of the brain and your memory through the MCP tools. |
handoff-session | Writes a handoff brief (goal, state, decisions, next steps, gotchas) into the standing handoff brain for exactly you and the teammates you name, so they can search it from their own agent; creates and shares that brain when there is none, or a one-off brain with --new. The session itself is not shared. |
link-pr | Links or unlinks a session and a pull request in MemHub. Asks the agent to run gh pr view. |
find-contributing-sessions | Reads this machine's Claude Code, Codex and Cursor session history to find the sessions behind your pull requests. Its script runs gh pr view and gh api for each PR's files. It prints paths, branches and commit ids, never transcript content, and links only the sessions you approve. |
pr-babysit | Polls a pull request's review bots and CI with gh. The agent fixes findings, commits, pushes and replies on review threads. Once the PR is clean, it saves a review record to the repository's brain. The pr_babysit_trigger hook asks the agent to start it after gh pr create. |
create-rule | Drafts a team rule, replays it over this machine's sessions, and files it as proposed with create_rule. |
start-rulebook | Fills starter rules in from a scan of the repository. If you choose, it also mines rules from your CLAUDE.md, the last 30 days of Claude Code, Codex and Cursor sessions on this machine, and Claude Code /insights facets. Working files go to a temp directory or mine-out/ in the current directory. It files rules as proposed and never activates one. |
spec, spec-work, spec-check, spec-maintain | Spec-driven work against Git specs or brain documents. A --cloud bootstrap runs on the MemHub backend. |
companion | Checks, shows or hides the companion (see Companion). |
Every hook is the same command, python3 "${CLAUDE_PLUGIN_ROOT}/scripts/hook_entry.py" <event> <name>. Hooks are declared in hooks/claude-hooks.json. scripts/hook_entry.py reads the hook input once. It checks any payload filter (on the tool call's command or tool name, never its output), then runs the named script with the same Python: in the same process for the hooks Claude Code waits on, in a separate process for the async ones. Every handler first passes claude_hook_guard. If the hook input comes from Cursor or Codex instead of Claude Code, the guard stops the handler. At a Cursor turn end, it starts the plugin's Cursor capture (cursor_flush.py) instead.
Claude Code always sets CLAUDE_PLUGIN_ROOT for plugin hooks. A host that loads hooks/claude-hooks.json without setting it gets the path /scripts/hook_entry.py. Python then exits 2, and the host treats exit 2 on PreToolUse, UserPromptSubmit and Stop as a block. Set CLAUDE_PLUGIN_ROOT to the plugin folder when you run these hooks in another host.
| Event (matcher) | Name | What it does | Sends data? |
|---|---|---|---|
PreToolUse (Bash, Edit, MultiEdit, Write, NotebookEdit, Read) | rulebook_hook pre | Checks the call against your cached team rules. It can add an advisory or deny the call when a gate rule matches. It refreshes the rule cache in a detached background process once the cache is a minute old. | Yes, see Rulebook |
PreToolUse (mcp__*__add_memory) | add_memory_gate | Denies MemHub's add_memory while this plugin is already capturing the session, so a turn isn't stored twice. | No |
PreToolUse (mcp__*__create_rule) | create_rule_origin | Adds this session's id and the current turn to MemHub's create_rule call (not to a harness draft, which carries its own), so the rule records the session it was filed from. Never approves or blocks the call. | Yes, the session id and turn, inside the create_rule call |
PostToolUse (Bash) | flush_session | After a command that actually ran git commit, gh pr create or gh pr merge, uploads the transcript so far. Runs in the background. | Yes, the transcript |
PostToolUse (Edit, MultiEdit, Write, NotebookEdit) | artifact_sync_reminder | If the edited file belongs to a spec in the repository (by spec frontmatter), reminds the agent once per session. | No |
PostToolUse (Bash) | pr_babysit_trigger | After a successful gh pr create, tells the agent to start a /memhub:pr-babysit loop on the new PR. | No |
PostToolUse (Edit, MultiEdit, Write, Bash) | md_capture | Records which Markdown files the session wrote, or for Bash the working directory, in a local state file, and touches a per-session activity marker. | No |
PostToolUse (Bash, Edit, MultiEdit, Write, NotebookEdit, Read) | rulebook_hook post | Rule advisories on failed results and on files a Bash call wrote, plus tracking for ordering rules. | Yes, see Rulebook |
PostToolUse (Bash, mcp__*github*__*) | pr_link_trigger | When a GitHub call's output names exactly one pull request, asks MemHub whether to link this session to it. It may then tell the agent to call the link_pr tool. | Yes, the PR URL |
| SessionStart | capture_health | Warns you when capture is unauthenticated or recently failed. Checks plugin compatibility with MemHub and whether a newer release exists. | Yes, see Network destinations |
| SessionStart | brain_brief brief | Gives the agent a short map of the repository's agent brain from a local cache. Starts a detached process that refreshes its recall pointers. | The detached process does |
| SessionStart | rulebook_hook session | Loads your team rules into the session. Fetches them first if the cache is stale. | Yes, the repository name |
| SessionStart | harness_stop session | Unless MEMHUB_HARNESS_EXTRACT turns it off. Tells the agent the hand-off rule, unseen by you. See Harness-tied rule drafting. | No |
| UserPromptSubmit | brain_brief prompt | Delivers brain pointers the session-start brief didn't have yet. | No |
| UserPromptSubmit | rulebook_hook prompt | Fires rules written for prompts. These only advise. | No. Fires are logged locally and uploaded at Stop |
| Stop | flush_turn | Uploads the transcript bytes written since the last successful upload. Runs in the background. | Yes, the transcript |
| Stop | brain_brief refresh | Refreshes the cached brain overview, at most every 6 hours. Runs in the background. | Yes, a brain id |
| Stop | md_capture_flush | Saves qualifying Markdown files as draft artifacts. Runs in the background. See Markdown capture. | Yes, file contents |
| Stop | rulebook_hook flush | Uploads the rule-fire and rule-event logs. Once a day it also deletes stale local state. Runs in the background. | Yes, identifiers |
| Stop | harness_stop stop | Unless MEMHUB_HARNESS_EXTRACT turns it off. | Yes, when on |
| SessionEnd | session_end | Runs flush_session.py (re-sends the whole transcript as a backstop), then rulebook_hook flush final. Runs in the background. | Yes, the transcript |
Sessions upload automatically through the import_conversation MCP tool, from the Stop, SessionEnd and commit/PR hooks above. They go to your personal memory, never into a brain. Each upload also carries the session's title, the repository name (the origin remote's basename) and any GitHub pull-request URLs that gh pr create printed in the session. Slash-command bookkeeping records are left out, and a tool result over 200,000 bytes is replaced by a note giving its size. Before upload, the plugin removes MemHub keys (mhk_…, xtk_…) from the records and the title. Nothing else is redacted. The server de-duplicates what it already has, so re-sending is safe. MEMHUB_TURN_FLUSH=0 turns off per-turn capture. The commit/PR and SessionEnd uploads still run.
At the end of each turn, md_capture_flush.py reads Markdown files the session wrote and saves them to MemHub as draft artifacts. These files come from Edit, Write or MultiEdit calls, or from git status in a directory where the session ran Bash. A file qualifies when:
.md file of 6,000 to 2,000,000 bytes; or.md file whose YAML frontmatter has the line memhub: artifact, which skips the 6,000-byte floor.Files found through git status must also be modified or untracked, and newer than the session's start.
A prefilter (md_capture_prefilter.py) skips the turn-end pass when nothing is pending: no Edit/Write path is waiting, and the last git status pass found nothing outstanding with no Bash, Edit, MultiEdit or Write call since. A file changed by anything else (your editor, a background job) after such a pass is picked up by the next turn that runs one of those calls, or within five minutes.
A file never qualifies if its path contains /.claude/, /scratchpad/, /tmp/, /private/tmp/, /var/folders/, /node_modules/ or /.git/. The same goes for files named CLAUDE.md, AGENTS.md or MEMORY.md and files in the OS temp directory. The git status search also skips ignored files, submodules, symlinks and paths outside the repository. At most five files are saved per turn, largest first. Content is redacted the same way as transcripts. Each save carries the auto-captured tag. It goes to the repository's agent brain when one is known (~/.config/memhub-plugin/rooms.json). When none is recorded, the plugin asks MemHub for a brain named after the origin remote's <org>/<name> and records the answer; a miss is asked again after a day. It never creates one. Without a brain, the file goes to your personal memory. A file goes up again only when its content changes. There is no setting that turns this off on its own. MEMHUB_TURN_FLUSH=0 does not affect it. To keep a file out, keep it below the size floor or in one of the excluded locations, or disable the plugin.
rulebook_hook.py sends:
MEMHUB_RULEBOOK_RECALL=0 to turn this matching off.MEMHUB_RULEBOOK_JUDGE=0 to turn this off.Every rule that fires is shown to you as 📏 Rule fired: …, or ⛔️ when a gate blocked the call. The agent is told to repeat the same line. A blocked Bash call can be overridden with RULEBOOK_OVERRIDE='<why>' <command>, and a blocked edit with a rulebook-override[<rule>]: <why> line in the new content. MEMHUB_RULEBOOK_FETCH=0 stops fetching rules, and the cached ones keep applying.
On Claude Code with mods (2.1.287+), the plugin's mod (mod/) checks these rules in-process and the command hooks step aside for the lanes it serves. MemHub can switch that off remotely: the mod reads mod_lanes from GET /v1/plugin/compatibility when the session starts (before it takes any lane) and every five minutes after. When it reads false, the mod hands every lane back to the command hooks for the rest of the session and the status line says MemHub rules: served by the command hooks (remote switch). A later true does not take them back; the next session (or a reload of the plugin) reads the switch afresh. A failed check changes nothing, and a server that sends no mod_lanes leaves the mod serving.
brain_brief.py runs a detached pointers process. It searches the repository's brain and your sessions with identifiers taken from the branch: PR and ticket numbers, plus the basenames of files changed against the default branch, left uncommitted, or changed in the last 20 commits. The search goes through the search_memory tool. MEMHUB_BRIEF_POINTERS=0 turns this off. pr_link_trigger.py sends the pull request URL to /v1/team/pr-links/check. It caches a "not connected" answer for 30 minutes (MEMHUB_PRLINK_NEGATIVE_TTL_S).
Unless MEMHUB_HARNESS_EXTRACT is set to something other than 1, on, true or yes (0 turns it off; unset or blank is on), each Stop rebuilds the previous turn from the transcript. It redacts that window (MemHub keys, home directories, e-mail addresses, command-line credentials) and sends it to /v1/team/rulebook/harness/classify. When the classifier signals a candidate rule, the agent is asked once to start a background fork of itself. The fork files a proposed rule with create_rule. Nothing is activated without a person. The local files, under ~/.config/memhub-plugin/harness/, are stop.log (one line per Stop, with no prompt text), offsets/ (where the next transcript read may start) and judged/ (one empty file per judged turn, so a machine with both the memhub and memhub-staging installs judges each turn once). harnessDrafting: false turns all of this off.
companion/register.ts is part of the plugin's Claude Code mod (a function-hook module: hooks/hooks.json loads mod/register.ts, which registers the companion last). Mods are on by default from Claude Code 2.1.287 (the desktop Code tab from 2.1.286) and load with the plugin; there is nothing to turn on, and CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is ignored. disableAllHooks, --safe-mode, an organization policy or Anthropic switching mods off remotely stops it. /<animal> off hides it. It writes nothing to ~/.claude/settings.json. The module draws an animal above the prompt and reacts to session, turn, prompt and tool events, including rule fires. It saves its preferences in Claude Code's plugin store. It reads the rules that fired from the mod's own session state, and the classic hook answer only where the mod's Rulebook lanes are off. It talks to MemHub itself, over Claude Code's HTTP client with your access key (resolved once a session by python3 scripts/rulebook_mod_cli.py api-info):
GET /v1/team/rulebook/rules?status=eq.proposed&author=eq.xtrace when the session starts, every five minutes, and when a turn ends, to list rules proposed from this session;PATCH /v1/team/rulebook/rules/<id> when you press Activate or Reject.The only processes it starts are open or xdg-open, to open a rule in MemHub Studio (https://mem.xtrace.ai) when you click it.
This package also carries Codex and Cursor manifests (.codex-plugin/, .cursor-plugin/, plugin.json, mcp.json) and hook configs (hooks/codex-hooks.json, hooks/cursor-hooks.json, hooks/cursor_capture.cmd). On those hosts the hooks run the same capture, rulebook and brain-brief scripts through scripts/codex_hook_bridge.py and scripts/cursor_capture.py, and Cursor capture also reads Cursor's session store under ~/.cursor/. /memhub:onboard merges MemHub's handlers into $CODEX_HOME/hooks.json (default ~/.codex/hooks.json), keeps unrelated handlers, backs up a file it changes, and copies a launcher to $CODEX_HOME/memhub_hook_bridge.py. Codex asks you to review the hooks before they run.
https://api.memhub.xtrace.ai: the MCP server (/mcp-server/mcp) and REST routes under /v1/. These cover rules, rule fires and fire events, the rule judge, pr-links, harness classify, /v1/plugin/compatibility and /v1/developer/access-tokens (used only by /memhub:login). Every request carries your credential, except the OAuth discovery requests /memhub:login sends before it has one. Plain http is refused. Requests through the shared transport (scripts/mcp_http.py) also carry an X-MemHub-Plugin-Version header and don't follow redirects. The access-key calls (scripts/pak.py) carry no version header and use Python's default URL opener, which follows a redirect on a GET.https://mem.xtrace.ai: the plugin sends nothing here. After /memhub:login succeeds, your browser is sent to the setup guide on this site.https://memhub-prod.us.auth0.com: OAuth login from /memhub:login and /mcp. Background hooks (capture flushes, the session brief, the rule judge) can also refresh a cached OAuth token here: a GET of the discovery document and a POST of the refresh token to its token endpoint. This happens only when no option, $MEMHUB_TOKEN or stored access key is set and the cached token is stale.https://raw.githubusercontent.com/XTraceAI/agent-plugins/: the release check at session start (scripts/plugin_updates.py). It reads the public marketplace manifest and plugin manifest, at most once an hour, with a 0.75-second timeout. No credentials or session data are sent. The result is cached in ~/.config/memhub-plugin/releases/.The plugin itself makes no other network calls. In particular, it makes no calls to the GitHub API of its own. GitHub traffic comes only from gh a
mod/register.ts 470 lines1// The plugin's one hooks module (spec §3.1, D1): a composition root that
2// registers each part in a fixed order. Hooks of one module run in
3// registration order, so:
4//
5// shell (session.start: claims → book → remote switch → session lane) → rulebook lanes
6// (tool.call pre+post, prompt.submit) → turn.complete (ledger) → companion
7//
8// The companion registers LAST, so its tool.call hook is innermost and sees
9// the rulebook's answer.
10//
11// The mod never builds on `classic.*`, `prompt.section`, `prompt.context`,
12// `prompt.compose`, `skill.prompt` or `attribution.text` (spec §1.4, D3): the
13// built-in guard skips user mods on those for most Team/Enterprise sign-ins.
14
15import type { EngineInterface, On } from 'claude-code'
16
17import type { FireNote, LaneName } from '../types'
18
19// The companion registers last (innermost) with the mod's ctx: it reads fires
20// from `$.state` and polls proposals over `$.http.fetch` (`register(on, ctx)`).
21import { register as registerCompanion } from '../companion/register'
22import { Act } from './act'
23import { Book, RemoteSwitch } from './book'
24import { Claims, StatusLine } from './claims'
25import { type Ctx, type Env, envOf, type Io, lastJson, makeCtx, MOD_CLI, rootOf } from './ctx'
26import type { Engine, EngineEnv, EngineIO, FileStat, HookRule, PathsInfo, TurnMessage } from './engine'
27import { createEngine } from './engine/engine'
28import { Lanes, TOOL_RX } from './lanes'
29
30/**
31 * The Ctx every part receives at register time. Its fields are the real
32 * ones from session.start on (the plugin's name — and so the environment —
33 * is only known through `$`); before that `env` reads 'staging', `root` ''
34 * and `api()` undefined.
35 */
36function lateCtx(): Ctx & { bind(real: Ctx): void } {
37 let real: Ctx | undefined
38 return {
39 get env(): Env {
40 return real?.env ?? 'staging'
41 },
42 get root() {
43 return real?.root ?? ''
44 },
45 api: () => (real ? real.api() : Promise.resolve(undefined)),
46 forgetApi: () => real?.forgetApi(),
47 bind(r) {
48 real = r
49 },
50 }
51}
52
53// ── the real port, over `$` ─────────────────────────────────────────────────
54//
55// `$.env` and `$.state` take literals only (claude plugin validate reads them
56// off the source), so each name is spelled out. promote_export.py renames the
57// plugin to `memhub` for prod and must rewrite the `'memhub-staging'` state
58// refs below (and types/index.d.ts) with it: a plugin may write only its own
59// state.
60
61const FIRES = { plugin: 'memhub', key: 'fires' } as const
62const LANES = { plugin: 'memhub', key: 'lanes' } as const
63const HEALTH = { plugin: 'memhub', key: 'health' } as const
64
65export function ioOf($: EngineInterface): Io {
66 return {
67 pluginName: $.plugin.name,
68 root: rootOf($.plugin.root),
69 now: () => $.clock.now(),
70 sessionId: () => $.session.id(),
71 cwd: () => $.session.cwd(),
72 run: (argv, init) => $.process.run(argv, init),
73 fetch: (url, init) => $.http.fetch(url, init),
74 readFile: async path => {
75 const text: unknown = await $.fs.read(path)
76 if (typeof text !== 'string') throw new Error(`not text: ${path}`)
77 return text
78 },
79 writeFile: (path, text) => $.fs.write(path, text),
80 getLanesVar: env => (env === 'prod' ? $.env.get('MEMHUB_MOD_LANES_PROD') : $.env.get('MEMHUB_MOD_LANES_STAGING')),
81 setLanesVar: (env, value) =>
82 env === 'prod' ? $.env.set('MEMHUB_MOD_LANES_PROD', value) : $.env.set('MEMHUB_MOD_LANES_STAGING', value),
83 getState: async key => {
84 const read =
85 key === 'fires' ? await $.state.get(FIRES) : key === 'lanes' ? await $.state.get(LANES) : await $.state.get(HEALTH)
86 return read as { value: never; version: number }
87 },
88 setState: async (key, value) => {
89 if (key === 'fires') await $.state.set(FIRES, value as FireNote[])
90 else if (key === 'lanes') await $.state.set(LANES, value as LaneName[])
91 else await $.state.set(HEALTH, value as string)
92 },
93 status: text => $.ui.status(text),
94 log: text => $.ui.log(text),
95 debug: text => $.ui.log(text, { to: 'debug' }),
96 append: async (type, text) => {
97 const r: unknown = await $.session.append({ message: { type, content: [{ type: 'text', text }] } })
98 if (r && typeof r === 'object' && 'deny' in r && (r as { deny?: unknown }).deny) {
99 throw new Error(`session.append refused: ${String((r as { deny: unknown }).deny)}`)
100 }
101 },
102 every: (ms, fn) => $.clock.every(ms, fn),
103 sleep: ms => $.clock.sleep(ms),
104 }
105}
106
107/**
108 * What the engine needs from `$` beyond the shell's Io: built by `extrasOf`
109 * (the one place `$` is read for it); the tests leave it out.
110 */
111export type EngineExtras = {
112 stat(path: string, resolve?: boolean): Promise<FileStat | undefined>
113 /** The main conversation as ApiMessage (`$.session.messages({ as: "api" })`). */
114 messages(): Promise<readonly TurnMessage[] | undefined>
115 /** `$.session.turns()`: user prompts so far, the judge's per-turn id. */
116 turns(): Promise<number>
117 env(): Promise<EngineEnv>
118 home(): Promise<string>
119 sleep(ms: number): Promise<void>
120}
121
122export function extrasOf($: EngineInterface): EngineExtras {
123 return {
124 stat: async (path, resolve) => {
125 try {
126 const st = await $.fs.stat(path, resolve ? { resolve: true } : undefined)
127 return { kind: st.kind, size: st.size, mtimeMs: st.mtimeMs, ...(st.realPath ? { realPath: st.realPath } : {}) }
128 } catch {
129 return undefined
130 }
131 },
132 messages: async () => {
133 try {
134 return (await $.session.messages({ as: 'api' })) as unknown as TurnMessage[]
135 } catch {
136 return undefined
137 }
138 },
139 turns: () => $.session.turns(),
140 env: async () => ({
141 recall: await $.env.get('MEMHUB_RULEBOOK_RECALL'),
142 judge: await $.env.get('MEMHUB_RULEBOOK_JUDGE'),
143 timeoutS: await $.env.get('MEMHUB_RULEBOOK_TIMEOUT_S'),
144 baseBranch: await $.env.get('MEMHUB_RULEBOOK_BASE_BRANCH'),
145 briefBudget: await $.env.get('MEMHUB_BRIEF_TOKEN_BUDGET'),
146 }),
147 home: async () => (await $.env.get('HOME')) ?? '',
148 sleep: ms => $.clock.sleep(ms),
149 }
150}
151
152/** `read_facts`' newline count for a file past the `$.fs` read cap, by the Python's own loop. */
153const COUNT_LINES_PY =
154 'import sys\n' +
155 'total, seen, last = 0, 0, b"\\n"\n' +
156 'with open(sys.argv[1], "rb") as f:\n' +
157 ' while seen < (64 << 20):\n' +
158 ' chunk = f.read(1 << 20)\n' +
159 ' if not chunk: break\n' +
160 ' total += chunk.count(b"\\n"); seen += len(chunk); last = chunk[-1:]\n' +
161 'if seen and last != b"\\n": total += 1\n' +
162 'print(total)\n'
163
164/** The engine's own I/O, over the shell's port (and `$`, through `extras`). */
165function engineIoOf(io: Io, ctx: Ctx, extras: EngineExtras | undefined, home: string): EngineIO {
166 const pathsCache = new Map<string, Promise<PathsInfo | undefined>>()
167 return {
168 git: async (argv, cwd, timeoutMs = 10_000) => {
169 const r = await io.run(['git', ...argv], { cwd, timeoutMs })
170 return { code: r.exitCode, stdout: r.stdout }
171 },
172 readText: path => io.readFile(path).catch(() => undefined),
173 writeText: (path, text) => io.writeFile(path, text).catch(() => undefined),
174 stat: (path, resolve) => (extras ? extras.stat(path, resolve) : Promise.resolve(undefined)),
175 countLines: async path => {
176 try {
177 const r = await io.run(['python3', '-c', COUNT_LINES_PY, path], { timeoutMs: 10_000 })
178 const n = Number(r.stdout.trim())
179 return r.exitCode === 0 && Number.isInteger(n) ? n : undefined
180 } catch {
181 return undefined
182 }
183 },
184 now: () => Date.now(),
185 sleep: ms => (extras ? extras.sleep(ms) : new Promise<void>(() => undefined)),
186 tzOffsetMinutes: ms => -new Date(ms).getTimezoneOffset(),
187 home,
188 env: () => (extras ? extras.env() : Promise.resolve({})),
189 paths: dir => {
190 let p = pathsCache.get(dir)
191 if (!p) {
192 p = (async () => {
193 const r = await io.run(['python3', `${ctx.root}/${MOD_CLI}`, 'paths', '--env', ctx.env, '--cwd', dir], { timeoutMs: 10_000 })
194 const got = lastJson(r.stdout) as Record<string, unknown> | undefined
195 if (r.exitCode !== 0 || !got || typeof got.base !== 'string') return undefined
196 return { repo: typeof got.repo === 'string' ? got.repo : '', root: typeof got.root === 'string' ? got.root : null, base: got.base }
197 })().catch(() => undefined)
198 pathsCache.set(dir, p)
199 // A failed answer is not kept: the next call asks again.
200 void p.then(v => {
201 if (!v) pathsCache.delete(dir)
202 })
203 }
204 return p
205 },
206 state: async (ops, cwd) => {
207 try {
208 const r = await io.run(['python3', `${ctx.root}/${MOD_STATE}`], { stdin: JSON.stringify({ cwd, ops }), timeoutMs: 10_000 })
209 const got = lastJson(r.stdout) as { results?: unknown } | undefined
210 return r.exitCode === 0 && got && Array.isArray(got.results) ? (got.results as Record<string, unknown>[]) : undefined
211 } catch {
212 return undefined
213 }
214 },
215 judge: async body => {
216 const api = await ctx.api()
217 if (!api) return undefined
218 try {
219 const r = await io.fetch(`${api.base}/v1/team/rulebook/judge`, {
220 method: 'POST',
221 headers: { Authorization: `Bearer ${api.bearer}`, 'Content-Type': 'application/json', Accept: 'application/json' },
222 body: JSON.stringify(body),
223 })
224 if (r.status === 401) ctx.forgetApi()
225 if (!r.ok) return { status: r.status }
226 if (!r.text.trim()) return { status: r.status, data: null }
227 const p = JSON.parse(r.text) as unknown
228 // mcp_http.rest: a non-zero envelope code is a failure the transport reported as 2xx.
229 if (p && typeof p === 'object' && 'code' in p) {
230 const env = p as { code?: unknown; data?: unknown }
231 if (env.code !== 0) return undefined
232 return { status: r.status, data: 'data' in env ? env.data : p }
233 }
234 return { status: r.status, data: p }
235 } catch {
236 return undefined
237 }
238 },
239 turn: async () => {
240 if (!extras) return undefined
241 const [messages, n] = await Promise.all([extras.messages(), extras.turns().catch(() => -1)])
242 return messages ? { id: n >= 0 ? `mod-turn-${n}` : '', messages } : undefined
243 },
244 }
245}
246
247/** This plugin's manifest version: the `hook_version` the mod reports (spec §4.1). */
248async function versionOf(io: Io): Promise<string | undefined> {
249 try {
250 const v = (JSON.parse(await io.readFile(`${io.root}/.claude-plugin/plugin.json`)) as { version?: unknown }).version
251 return typeof v === 'string' && /^\d{1,6}\.\d{1,6}\.\d{1,6}$/.test(v) ? v : undefined
252 } catch {
253 return undefined
254 }
255}
256
257/**
258 * A matcher every event of its kind matches. One module may hook an event
259 * once without a matcher (a second is refused at load), and the companion
260 * already hooks `session.start` and `turn.complete` bare; a matcher on the
261 * shell's own makes them distinct registrations without narrowing them.
262 */
263/** The shared-state writer (scripts/rulebook_mod_state.py): the Python's own lock. */
264const MOD_STATE = 'scripts/rulebook_mod_state.py'
265
266const EVERY_SESSION = { cwd: /^/ } as const
267const EVERY_TURN = { turnId: /^/ } as const
268
269/** Everything the shell holds for the session, built at session.start. */
270export type Shell = {
271 io: Io
272 ctx: Ctx
273 claims: Claims
274 act: Act
275 lanes: Lanes
276 book: Book | undefined
277 /** The remote `mod_lanes` switch; undefined where there is no engine. */
278 remote?: RemoteSwitch
279 /** Settles once the book is held and the lanes are claimed, or the load gave up. */
280 ready: Promise<void>
281 /** The first prompt of the process has come: startup is past (see `boot`). */
282 firstPrompt(): Promise<void>
283 /**
284 * At every prompt, before the lanes: a session id that changed in-process
285 * (/clear, resume) gets the claim re-stamped (claims.ts `restamp`). Python
286 * served that new session until now, its SessionStart preamble included,
287 * so the session lane counts it as delivered.
288 */
289 sessionChanged(): Promise<void>
290}
291
292/**
293 * The shell's session.start, without `$` (so a test drives it through a fake
294 * Io): claims → book → remote switch → session lane.
295 *
296 * `pastStartup` is false in a fresh process: the startup session's posture
297 * preamble is then Python's (its SessionStart hook runs before anything here
298 * could claim), and `session` is claimed only once the first prompt has come
299 * AND the book is held, whichever is later. A hot reload is long past startup.
300 */
301export async function boot(
302 io: Io,
303 ctx: Ctx,
304 makeEngine: ((io: EngineIO) => Engine) | undefined,
305 opts: {
306 cwd: string
307 pastStartup: boolean
308 toHook?: (row: Record<string, unknown>) => HookRule | null
309 /** What the engine reads off `$` (extrasOf); absent in the shell's tests. */
310 extras?: EngineExtras
311 },
312): Promise<Shell> {
313 const status = new StatusLine(io)
314 const claims = new Claims(io, ctx.env, status)
315 const act = new Act(io, ctx, status)
316 // Whatever the variable lists now is not this module's to serve yet: an
317 // inherited value (a `claude` started from Bash) or the module before a
318 // reload. Python serves until the claim below.
319 await claims.forgetInherited()
320 let pastStartup = opts.pastStartup
321 let isLoaded = false
322 if (!makeEngine) {
323 await claims.failLoad()
324 const lanes = new Lanes(io, NO_ENGINE, claims, act)
325 return { io, ctx, claims, act, lanes, book: undefined, ready: Promise.resolve(), firstPrompt: async () => {}, sessionChanged: async () => {} }
326 }
327 const home = opts.extras ? await opts.extras.home().catch(() => '') : ''
328 const engine = makeEngine(engineIoOf(io, ctx, opts.extras, home))
329 const hookVersion = await versionOf(io)
330 const book = new Book(io, ctx, engine, claims, status, { hookVersion, toHook: opts.toHook })
331 // The remote switch (book.ts): a false gives every lane to Python for the
332 // session, and stops the book's refresh, which then serves nothing.
333 const remote = new RemoteSwitch(io, ctx, claims, status, { hookVersion, onOff: () => book.stop() })
334 const lanes = new Lanes(io, engine, claims, act)
335 // Claim late, in the background: the first prompt never waits on the book,
336 // and until it is held Python serves every lane.
337 const ready = (async () => {
338 try {
339 if (!(await book.load(opts.cwd))) return
340 // Before any claim: switched off, nothing is claimed (and `firstPrompt`
341 // claims nothing either: every lane is released for the session).
342 if (!(await remote.allowsClaim())) return
343 isLoaded = true
344 await claims.claim(pastStartup ? ['pre', 'post', 'prompt', 'session'] : ['pre', 'post', 'prompt'])
345 await lanes.sessionStart(true)
346 book.start()
347 remote.start()
348 } catch (err) {
349 act.unhealthy('load', err)
350 await claims.failLoad().catch(() => undefined)
351 }
352 })()
353 return {
354 io,
355 ctx,
356 claims,
357 act,
358 lanes,
359 book,
360 remote,
361 ready,
362 async firstPrompt() {
363 if (pastStartup) return
364 pastStartup = true
365 if (isLoaded) await claims.claim(['session'])
366 },
367 async sessionChanged() {
368 if (await claims.restamp()) await lanes.sessionStart(true)
369 },
370 }
371}
372
373/** Stands in where there is no engine: every lane is released, so nothing calls it. */
374const NO_ENGINE: Engine = {
375 setBook() {},
376 pre: () => Promise.reject(new Error('no engine')),
377 post: () => Promise.reject(new Error('no engine')),
378 prompt: () => Promise.reject(new Error('no engine')),
379 session: () => Promise.reject(new Error('no engine')),
380}
381
382export function register(on: On) {
383 const ctx = lateCtx()
384 let shell: Shell | undefined
385
386 // ── shell: claims → book → remote switch → session lane ───────────────────
387 on('session.start', EVERY_SESSION, async ($: EngineInterface, e, next) => {
388 // The shell failing to start must never cost the companion its start:
389 // caught here, and the shell stays off (no claim, so Python serves).
390 shell = undefined
391 try {
392 const io = ioOf($)
393 const env = envOf(io.pluginName)
394 if (env) {
395 const real = makeCtx(io, env)
396 ctx.bind(real)
397 // A fresh process has no `lanes` in $.state; a hot reload keeps it.
398 const pastStartup = (await io.getState('lanes')).version > 0
399 shell = await boot(io, real, createEngine, { cwd: e.cwd, pastStartup, extras: extrasOf($) })
400 }
401 } catch {
402 shell = undefined
403 }
404 return next(e)
405 }).catch(($, e, next) => next(e))
406
407 // ── rulebook lanes ────────────────────────────────────────────────────────
408 on('tool.call', { tool: TOOL_RX }, async ($, e, next) => {
409 if (!shell) return next(e)
410 return shell.lanes.toolCall(e, next as never) as never
411 }).catch(async ($, e, next) => {
412 // Overran or threw outside the lanes' own handling: hand the call to Python.
413 // `next` here is replay-safe (claude-code.d.ts `Caught`): when the hook had
414 // called it (`next.called`), `next(e)` resolves to what that call settled
415 // to and nothing beneath runs again, so the tool never runs twice; the
416 // d.ts's own pattern for a tool.call guard is
417 // `next.called ? next(e) : { deny }`. Not called: the call has not run,
418 // and Python's hook beneath serves it once `pre` is out of the claim.
419 if (!next.called && shell?.claims.has('pre')) {
420 await shell.claims.fail('pre').catch(() => undefined)
421 try {
422 return await next(e)
423 } finally {
424 await shell.claims.recover('pre').catch(() => undefined)
425 }
426 }
427 return next(e)
428 })
429
430 on('prompt.submit', async ($, e, next) => {
431 if (!shell) return next(e)
432 // Re-stamp the claim first: until it carries this session's id, Python serves.
433 await shell.sessionChanged().catch(() => undefined)
434 await shell.firstPrompt()
435 return shell.lanes.promptSubmit(e, next as never) as never
436 }).catch(async ($, e, next) => {
437 // Replay-safe `next`, as for tool.call above.
438 if (!next.called && shell?.claims.has('prompt')) {
439 await shell.claims.fail('prompt').catch(() => undefined)
440 try {
441 return await next(e)
442 } finally {
443 await shell.claims.recover('prompt').catch(() => undefined)
444 }
445 }
446 return next(e)
447 })
448
449 // A compaction summarises the preamble away; the next prompt brings it back,
450 // and the next fire the no-echo note (act.ts NO_ECHO_NOTE).
451 on('session.compact', async ($, e, next) => {
452 const r = await next(e)
453 if (shell && e.trigger !== 'precompute' && e.agentId === undefined && !('skip' in r)) {
454 const sessionId = await shell.io.sessionId()
455 shell.lanes.compacted(sessionId)
456 shell.act.compacted(sessionId)
457 }
458 return r
459 }).catch(($, e, next) => next(e))
460
461 // The turn's batched ledger events (spec §4.7).
462 on('turn.complete', EVERY_TURN, async ($, e, next) => {
463 if (shell) await shell.act.flush(await shell.io.sessionId())
464 return next(e)
465 }).catch(($, e, next) => next(e))
466
467 // ── companion: last, so innermost ─────────────────────────────────────────
468 registerCompanion(on, ctx)
469}
470companion/register.ts 1280 lines1// One entry point: mod/register.ts, the plugin's hooks module (hooks.json),
2// calls `register(on, ctx)` with the mod's Ctx (mod/ctx.ts), last of its
3// parts, so the companion's hooks sit innermost. MemHub calls use
4// `ctx.api()`: the REST base, the bearer, and the Studio web app paired with
5// the base; `ctx.env` says which MemHub it is.
6//
7// It reads what the mod writes to `$.state` (fires, proposals,
8// lanes) and never needs the classic.* events, which the built-in
9// `sec-default` guard skips for user mods on Team/Enterprise sign-ins (Claude
10// Mods Migration Spec §1.4, D3). The classic PreToolUse / PostToolUse hooks
11// stay only as the fallback for lanes the mod has not claimed.
12
13import type { EngineInterface, On, Timer } from 'claude-code'
14import { read, update } from 'claude-code'
15
16import type { Api, Ctx } from '../mod/ctx'
17import type { FireNote, ProposalNote } from '../types'
18import type { Animal, Build, Painted, Tone } from './animal'
19import { ANIMALS, DEFAULT_ANIMAL } from './animals'
20import {
21 decisionOf, envName, firesOf, freshFires, headersOf, isApi, isClassicLane, lanesOf, listedRules,
22 mergedProposals, PROPOSED_PATH, proposalsKey, proposalsOf, proposedOfNote, rulePath, STATUS_OF, studioUrl, UUID,
23 versionOfManifest,
24} from './feed'
25import {
26 announcedIn, announcedOf, type Decision, decisionSaid, nameOf, type Proposed, proposalSaid, RULE_WORD,
27 withAnnounced,
28} from './proposed'
29import { type Fired, firedOf } from './rules'
30import { BUB_BG_HEX, CELL_H, CELL_W, compose, DESKTOP_TEXT_LINES, encode, fitBubble, footerOf, type Layout, layoutOf, petAt, SCALES, svgOf, wordAt } from './screen'
31import { pick, poolOf, prefsOf, type Prefs, remember, sessionsOf } from './selection'
32
33/** What the mod's act side writes on each fire (types/index.d.ts). */
34const FIRES = { plugin: 'memhub', key: 'fires' } as const
35/** Rules waiting on an answer: the poll below writes them. */
36const PROPOSALS = { plugin: 'memhub', key: 'proposals' } as const
37/** The Rulebook lanes the mod serves now; the classic hooks announce the rest. */
38const LANES = { plugin: 'memhub', key: 'lanes' } as const
39/** How often the server is asked for proposed rules, besides session start and each turn's end. */
40const POLL_MS = 300_000
41
42/**
43 * The companion in the band above the prompt: MemHub's face in the session,
44 * there to show what the plugin is doing for it. It sleeps while nothing
45 * happens, looks around while Claude answers or you type, and rises to speak
46 * when a MemHub rule fires, announcing the rule — calmly for an advisory,
47 * crossly for a call the rule stopped — and when the harness has proposed a
48 * new rule, presenting it and asking: Activate, Reject or Later, as buttons
49 * inside its bubble (1, 2, 3 from an empty prompt) until it is answered.
50 *
51 * This is the whole of what watches the session. Which animal does the
52 * sleeping and speaking is the Animal contract's business (see animal.ts):
53 * the director asks for a pose and draws what comes back, and knows nothing
54 * else about it. Every pose plays whole; it cuts in only where the animal is
55 * merely waiting (asleep, looking).
56 *
57 * Which animal shows is the session's own (see selection.ts): picked from the
58 * session id unless someone pinned one, and changed for this session alone by
59 * `/hippo`, `/goose`, ... — each of which also takes `always` (pin it for
60 * every new session), `random` (drop the pin), `never` / `include` (leave it
61 * out of, or put it back in, the random pick), `off`, `on`, `auto`, and —
62 * where the animal offers a demo — `demo` and its own pose names.
63 *
64 * A click on the ♥ beside its ground pets it. The animal itself cannot take
65 * the click: a Raster takes none, and a surface module laid over it to take
66 * them froze the pixels under it, while one laid beneath it never got them. A
67 * proposal's `rule↗` opens the rule in MemHub Studio.
68 *
69 * The terminal draws the band as a Raster, repainted in place by `$.ui.blit`.
70 * The desktop lists a Raster but draws nothing for one and refuses its blits,
71 * so there the same cells are drawn as an SVG and each changed frame redraws
72 * the band; its buttons sit in a row of their own, since a Box is placed in
73 * cells and the SVG is sized in CSS pixels, which need not agree.
74 */
75
76const FPS = 20
77/** The desktop redraws the band for a frame, not a blit: every frame that changed. */
78const DESKTOP_STRIDE = 1
79const RASTER_KEY = 'companion'
80const STORE_ENABLED = 'companion.enabled'
81// `companion.animal`, the old global pick, is neither read nor written: most
82// of it was `/goose` run to turn the band on, not a choice. Older versions
83// still read it, so it is left where it is.
84const STORE_PIN = 'companion.pin'
85const STORE_NEVER = 'companion.never'
86const STORE_SESSIONS = 'companion.sessions'
87const STORE_SCALE = 'companion.scale'
88/** Per session, the proposed rules already announced: a reload must not announce them again. */
89const STORE_ANNOUNCED = 'companion.announced'
90/** How long a keystroke keeps the animal looking, and a finished turn too. */
91const LINGER_FRAMES = 3 * FPS
92/** Announcements waiting their turn; past this the oldest waiting one drops. */
93const MAX_QUEUED = 3
94/** The key of the ♥ at the animal's feet that pets it. */
95const PET_KEY = 'companion-pet'
96/** How long a click waits for the animal to be free to be petted; then it drops. */
97const PET_PENDING_FRAMES = 5 * FPS
98/** The director's own pet, for an animal without one: hearts over `look`. */
99const FALLBACK_PET_FRAMES = 30
100const HEART_RGB: [number, number, number] = [240, 124, 150]
101/** How long after a /clear or /resume the frame clock keeps asking for the new session id. */
102const REPICK_FRAMES = 10 * FPS
103
104/** A pose the director asks the animal for, plus its own idle `offscreen`. */
105type PoseName = 'offscreen' | 'enter' | 'sleep' | 'wake' | 'look' | 'rise' | 'speak' | 'leave' | 'demo' | 'pet'
106
107type Segment = { name: PoseName; frames: Iterator<unknown> }
108
109/** One thing the animal has to say, in the bubble's tone. */
110type Said = { text: string; tone: Tone }
111
112/** What the session wants of the animal right now. */
113type Want = 'sleep' | 'look' | 'speak'
114
115/** Everything the companion keeps between frames and events. */
116type Band = {
117 animal: Animal
118 /** The animal's art for the size being drawn; its poses are the ones running. */
119 build: Build
120 fires: number
121 queue: Said[]
122 /** Proposed rules waiting on the person's answer; the first has the buttons. */
123 asks: Proposed[]
124 /**
125 * Rule ids already announced: the server lists a proposal until it is
126 * decided. Kept in the store too (STORE_ANNOUNCED), per session, since a
127 * reload of this module starts this set empty.
128 */
129 announced: Set<string>
130 /** An answer is on its way to MemHub; the buttons wait for it. */
131 isDeciding: boolean
132 /** The demo is showing a proposal, so it shows the buttons too. */
133 hasDemoAsk: boolean
134 /** What a press in the demo would have done, said in place of doing it. */
135 demoNote: string | null
136 /** The frame the demo's note gives the buttons back. */
137 demoNoteUntil: number
138 /** Where the buttons sat when last drawn (see buttonsAt), so a move redraws. */
139 buttonsKey: string
140 /** Where the proposal's linked `rule` sat when last drawn (see wordOf), likewise. */
141 wordKey: string
142 /** The tone of what the animal is saying now, while a `speak` plays. */
143 speakingTone: Tone | null
144 isEnabled: boolean
145 /** A forced pixel size, or null to let the room pick as hippo_half.py does. */
146 scale: number | null
147 /** Rows the band really gave the drawing, once it has said; see ui.render. */
148 roomRows: number | null
149 /** The width and allowance the room was measured at; a change measures again. */
150 roomAt: number | null
151 roomMax: number | null
152 /** `auto` follows the session; anything else is the animal's own demo. */
153 mode: string
154 isWorking: boolean
155 /** The frame until which a keystroke or a finished turn keeps it looking. */
156 activeUntil: number
157 seg: Segment
158 frame: number
159 current: Painted
160 timer: Timer | null
161 requestId: string | null
162 /** Where the band was last drawn: the terminal blits a frame, the desktop redraws it. */
163 surface: 'terminal' | 'desktop'
164 layout: Layout | null
165 lastCells: string
166 /** The session the animal was picked for. */
167 sessionId: string
168 /**
169 * A /clear or /resume moved the process to another session: pick again once
170 * its id is readable — tried on every prompt edit and turn until then, and
171 * by the frame clock until `repickUntil`.
172 */
173 needsRepick: boolean
174 repickUntil: number
175 isPicking: boolean
176 /** Bumped by every command that picks an animal, so a pick already in flight yields to it. */
177 pickGen: number
178 /** An animal picked by a command before the engine reported the new session's id: it is that session's. */
179 pendingPick: string | null
180 /** A click waits, until this frame, for the animal to be free to be petted. */
181 petUntil: number
182 /** The frame the director's own pet began, for its hearts. */
183 petAt: number
184 /** Got it: the speech's bubble is hidden and its holds skipped. */
185 isHushed: boolean
186 /** The last frame a hushed speech showed, so a hold's repeats are skipped. */
187 hushedFrame: string
188 /** A page is being opened in the browser; another press waits for it. */
189 isOpening: boolean
190 /** setUp has run (or is running) for this load of the module. */
191 isSetUp: boolean
192 /** The mod's Ctx, from mod/register.ts: the credential and the env. */
193 ctx: Ctx
194 /**
195 * The `$.state` fires already seen, by feed.fireKey; null until the first
196 * read of this load, which takes what is there as said (a reload must not
197 * say a session's fires again).
198 */
199 seenFires: Set<string> | null
200 /** The proposals last handed to the band (feed.proposalsKey); null before any. */
201 proposalsKey: string | null
202 /** Proposal updates, one after another, so two never announce the same rule. */
203 syncing: Promise<void>
204 pollTimer: Timer | null
205 isPolling: boolean
206 /** The MemHub the poll reached ('staging' | 'production'); '' until it has. */
207 env: string
208}
209
210export function register(on: On, ctx: Ctx) {
211 const band: Band = {
212 animal: DEFAULT_ANIMAL,
213 build: DEFAULT_ANIMAL.builds[0]!,
214 fires: 0,
215 queue: [],
216 asks: [],
217 announced: new Set(),
218 isDeciding: false,
219 hasDemoAsk: false,
220 demoNote: null,
221 demoNoteUntil: 0,
222 buttonsKey: '',
223 wordKey: '',
224 speakingTone: null,
225 isEnabled: true,
226 scale: null,
227 roomRows: null,
228 roomAt: null,
229 roomMax: null,
230 mode: 'auto',
231 isWorking: false,
232 activeUntil: 0,
233 seg: { name: 'offscreen', frames: [][Symbol.iterator]() },
234 frame: 0,
235 current: { canvas: [] },
236 timer: null,
237 requestId: null,
238 surface: 'terminal',
239 layout: null,
240 lastCells: '',
241 sessionId: '',
242 needsRepick: false,
243 repickUntil: 0,
244 isPicking: false,
245 pickGen: 0,
246 pendingPick: null,
247 petUntil: 0,
248 petAt: 0,
249 isHushed: false,
250 hushedFrame: '',
251 isOpening: false,
252 isSetUp: false,
253 ctx,
254 seenFires: null,
255 proposalsKey: null,
256 syncing: Promise.resolve(),
257 pollTimer: null,
258 isPolling: false,
259 env: '',
260 }
261
262 on('session.start', async ($, e, next) => {
263 const result = await next(e)
264 // `claude -p` is a terminal that is not interactive, with no band to draw
265 // in. The desktop reports no surface here and `isInteractive: false` (a
266 // live 2.1.287 session), so it sets up from its first draw instead.
267 if (e.surface === 'terminal' && e.isInteractive) {
268 await setUp($, band)
269 }
270 return result
271 })
272
273 for (const animal of ANIMALS) {
274 on('command.run', { command: commandOf(animal) }, async ($, e) => {
275 const arg = e.args.trim().toLowerCase()
276 if (arg === 'off') {
277 band.isEnabled = false
278 stop(band)
279 // the frame clock stops with the band: a click from before must not
280 // pet the animal once it is back
281 band.petUntil = 0
282 await $.store.set(STORE_ENABLED, false).catch(() => undefined)
283 $.ui.invalidate('ui.render')
284 return { text: `The ${animal.name.toLowerCase()} goes away. /${commandOf(animal)} brings it back.` }
285 }
286 const scaleAsked = /^scale +([1-4])$/.exec(arg)
287 if (scaleAsked || arg === 'scale auto') {
288 band.scale = scaleAsked ? Number(scaleAsked[1]) : null
289 await $.store.set(STORE_SCALE, band.scale).catch(() => undefined)
290 $.ui.invalidate('ui.render')
291 const fits = band.layout ? ` (showing ${band.layout.s})` : ''
292 return { text: `${commandOf(animal)}: pixel size ${band.scale ?? 'auto'}${fits}` }
293 }
294 const key = commandOf(animal)
295 if (arg === 'random') {
296 await $.store.delete(STORE_PIN).catch(() => undefined)
297 return { text: `${key}: new sessions pick their own animal from the session id` }
298 }
299 if (arg === 'never' || arg === 'include') {
300 const prefs = await prefsFor($)
301 const never = prefs.never.filter(n => n !== key)
302 if (arg === 'never') {
303 if (ANIMALS.every(a => a === animal || never.includes(commandOf(a)))) {
304 return { text: `${key}: it is the last animal in the random pick; include another first` }
305 }
306 never.push(key)
307 if (prefs.pin === key) await $.store.delete(STORE_PIN).catch(() => undefined)
308 }
309 await $.store.set(STORE_NEVER, never).catch(() => undefined)
310 return { text: arg === 'never' ? `${key}: left out of the random pick` : `${key}: back in the random pick` }
311 }
312 const isAlways = arg === 'always'
313 const isDemo = arg !== '' && arg !== 'on' && arg !== 'auto' && !isAlways
314 const demoBuild = buildFor(animal, band.scale)
315 if (isDemo && !(demoBuild.demo && (arg === 'demo' || (demoBuild.demoPoses ?? []).includes(arg)))) {
316 return { text: `usage: /${commandOf(animal)} ${usageOf(animal)}` }
317 }
318 // this command decides the session's animal: a pick still in flight for
319 // it yields, and one the engine's new session id (after a /clear or a
320 // /resume) was waiting for is this one
321 band.pickGen += 1
322 const id = await sessionIdOf($)
323 let isPending = false
324 if (id && id !== band.sessionId) {
325 band.sessionId = id
326 band.needsRepick = false
327 band.repickUntil = 0
328 } else if (band.needsRepick) {
329 // typed in the new session before the engine said its id: kept for it,
330 // and not written under the old session's id
331 band.pendingPick = key
332 isPending = true
333 }
334 const isSwitch = band.animal !== animal || !band.isEnabled
335 band.isEnabled = true
336 band.animal = animal
337 await $.store.set(STORE_ENABLED, true).catch(() => undefined)
338 if (band.sessionId && !isPending) {
339 const sessions = sessionsOf(await $.store.get(STORE_SESSIONS).catch(() => undefined))
340 await $.store.set(STORE_SESSIONS, remember(sessions, band.sessionId, key)).catch(() => undefined)
341 }
342 if (isAlways) {
343 const prefs = await prefsFor($)
344 await $.store.set(STORE_PIN, key).catch(() => undefined)
345 await $.store.set(STORE_NEVER, prefs.never.filter(n => n !== key)).catch(() => undefined)
346 }
347 if (isDemo) {
348 // another animal's demo plays in that animal's own art, from the start
349 band.build = buildFor(animal, band.scale)
350 play(band, arg)
351 } else if (isSwitch) {
352 switchTo(band, animal)
353 } else if (band.mode !== 'auto') {
354 play(band, 'auto')
355 }
356 start($, band)
357 $.ui.invalidate('ui.render')
358 if (isAlways) {
359 return { text: `${key}: every new session starts with ${titleOf(animal).toLowerCase()} (\`/${key} random\` undoes it)` }
360 }
361 return {
362 text:
363 band.mode === 'auto'
364 ? `${animal.name.toLowerCase()}: following the session`
365 : `${animal.name.toLowerCase()}: looping ${band.mode}`,
366 }
367 })
368 }
369
370 on('prompt.edit', ($, e, next) => {
371 band.activeUntil = band.frame + LINGER_FRAMES
372 nudge(band)
373 if (band.needsRepick) void repick($, band)
374 return next(e)
375 })
376
377 on('turn.start', async ($, e, next) => {
378 const result = await next(e)
379 band.isWorking = true
380 nudge(band)
381 if (band.needsRepick) void repick($, band)
382 return result
383 })
384
385 // A /clear or a /resume goes on under another session id with no
386 // session.start: that session shows its own animal, picked once the engine
387 // answers with its id.
388 on('session.end', async ($, e, next) => {
389 const result = await next(e)
390 if (e.reason === 'clear' || e.reason === 'resume') {
391 band.needsRepick = true
392 band.repickUntil = band.frame + REPICK_FRAMES
393 void repick($, band)
394 }
395 return result
396 })
397
398 // A rule the harness's background fork filed lands on the server minutes
399 // after the turn that launched it. Besides the clock (setUp), each main
400 // turn's end asks the server, without holding the turn — as each classic
401 // Stop used to, which the guard skips for most customers (D3).
402 on('turn.complete', async ($, e, next) => {
403 const result = await next(e)
404 if (e.agentId === undefined) {
405 band.isWorking = false
406 band.activeUntil = band.frame + LINGER_FRAMES
407 if (band.isSetUp) void pollProposals($, band)
408 }
409 return result
410 })
411
412 // The fallback for fires: the rulebook's command hooks sit beneath every
413 // hooks module in the classic chain, so `next(e)` is what they answered for
414 // this call. Read only while the mod has not claimed that lane: a claimed
415 // lane's fires come through `$.state` fires (see ui.render), and the Python
416 // hook it suppresses prints nothing here anyway. Where the built-in guard
417 // skips classic.* for user mods, these never run at all.
418 on('classic.PreToolUse', async ($, e, next) => {
419 const result = await next(e)
420 if (isClassicLane(await lanesNow($), 'pre')) {
421 announce(band, firedOf(result.additionalContext ?? [], result.deny ?? result.ask).map(saidOfFire))
422 }
423 return result
424 })
425
426 on('classic.PostToolUse', async ($, e, next) => {
427 const result = await next(e)
428 if (isClassicLane(await lanesNow($), 'post')) {
429 announce(band, firedOf(result.additionalContext ?? [], result.block).map(saidOfFire))
430 }
431 return result
432 })
433
434 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
435 // Read while drawing, so a write to either redraws the band and lands here.
436 await feedFromState($, band)
437 if (!band.isEnabled || e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
438 band.requestId = null
439 return next(e)
440 }
441 const { Box, Text, Button } = $.ui.resolve(e)
442 band.requestId = e.requestId
443 band.surface = e.surface
444 if (e.surface === 'desktop') void setUp($, band)
445 // hippo_half.py picks its pixel size from the terminal's height; the band
446 // gets less than that, and only says how much by how much it scrolled. So
447 // pick from the allowance, and where the drawing did not fit, remember the
448 // rows it actually got and pick again — shrinking until it sits whole.
449 if (band.roomAt !== e.props.bodyColumns || band.roomMax !== e.props.maxRows) {
450 band.roomAt = e.props.bodyColumns
451 band.roomMax = e.props.maxRows
452 band.roomRows = null
453 }
454 const ask = askOf(band)
455 // A waiting proposal's buttons sit inside its bubble, where Got it goes.
456 // Once the animal has said it and gone, they wait on a row of their own
457 // under it; that row is kept while one waits, so the drawing never jumps.
458 // On the desktop they always have that row (see the header).
459 const isRowed = e.surface === 'desktop' ? ask !== undefined : band.mode === 'auto' && band.asks.length > 0
460 const extra = isRowed ? 1 : 0
461 const room = Math.max(1, Math.min(e.props.maxRows, band.roomRows ?? e.props.maxRows) - extra)
462 const build = buildFor(band.animal, band.scale)
463 if (build !== band.build) {
464 // different art, different frames: it starts over rather than cutting
465 // from one drawing's pose into another's
466 band.build = build
467 band.seg = segment(band, 'offscreen')
468 band.current = { canvas: [] }
469 }
470 band.layout = layoutOf(build, e.props.bodyColumns, room, band.scale ?? undefined, linesOf(band), e.surface === 'desktop' ? 0 : 1)
471 if (!band.layout) {
472 const name = band.animal.name.toLowerCase()
473 const where = e.surface === 'desktop' ? 'window' : 'terminal'
474 return Text({ dimColor: true, children: `make the ${where} a little bigger for the ${name}` })
475 }
476 const { bodyRows } = e.props.scroll
477 if (bodyRows > 0 && bodyRows < band.layout.rows + extra && band.roomRows !== bodyRows) {
478 band.roomRows = bodyRows
479 $.ui.invalidate('ui.render')
480 }
481 // opens a rule's page in MemHub Studio. A pressable word rather than a
482 // link: a terminal without hyperlinks draws a Link or a Markdown link as
483 // its text and then its URL, which ran down the bubble past its bottom.
484 const opens = (url: string) => () => void openUrl($, band, url)
485 const petButton = () =>
486 Button({ key: PET_KEY, plain: true, dimColor: true, label: '♥', onPress: () => onClick(band) })
487 const at = buttonsAt(band, build, band.layout)
488 band.buttonsKey = keyOfButtons(at)
489 // under the animal, the rule's name opens it (#67's link), as `rule` does
490 const name = (ask: Proposed, width: number) =>
491 ask.url
492 ? Button({ key: 'rule-name', plain: true, label: nameOf(ask, width), hover: { underline: true }, onPress: opens(ask.url) })
493 : Text({ children: nameOf(ask, width) })
494 const controlsFor = (ask: Proposed) => {
495 const isDemo = ask === DEMO_ASK
496 const answer = (action: 'activate' | 'reject') =>
497 isDemo ? () => demoPress($, band, action) : () => void decide($, band, ask, action)
498 // plain, so each reads `1: Activate`: the digit is the key to press
499 return band.isDeciding
500 ? [Text({ color: '#968ca5', children: 'asking MemHub…' })]
501 : isDemo && band.demoNote
502 ? [Text({ color: '#968ca5', wrap: 'truncate-end', children: band.demoNote })]
503 : [
504 Button({ key: 'rule-activate', hotkey: '1', plain: true, label: 'Activate', onPress: answer('activate') }),
505 Button({ key: 'rule-reject', hotkey: '2', plain: true, label: 'Reject', onPress: answer('reject') }),
506 Button({
507 key: 'rule-later', hotkey: '3', plain: true, dimColor: true, label: 'Later',
508 onPress: isDemo ? () => demoPress($, band, 'later') : () => later($, band),
509 }),
510 ]
511 }
512 const waiting = (ask: Proposed) =>
513 Box({ key: 'rule-waiting', gap: 2, children: [Text({ dimColor: true, children: 'new rule waiting:' }), name(ask, 40), ...controlsFor(ask)] })
514
515 if (e.surface === 'desktop') {
516 const buf = compose(band.current, build, titleOf(band.animal), band.layout)
517 band.lastCells = encode(buf)
518 band.wordKey = ''
519 const art = Box({
520 alignItems: 'flex-end',
521 children: [
522 petButton(),
523 // no `key`: an Svg takes none (SvgProps), and Claude Code 2.1.292
524 // drops one from the drawn tree
525 $.ui.resolve(e).Svg({
526 source: svgOf(buf),
527 alt: `${titleOf(band.animal)}, the MemHub companion`,
528 width: band.layout.columns * CELL_W,
529 height: band.layout.rows * CELL_H,
530 }),
531 ],
532 })
533 if (!ask || at === null) return Box({ justifyContent: 'flex-end', children: art })
534 return Box({ flexDirection: 'column', alignItems: 'flex-end', children: [art, waiting(ask)] })
535 }
536
537 band.lastCells = cellsOf(band) ?? ''
538 const raster = $.ui.resolve(e).Raster({
539 key: RASTER_KEY,
540 columns: band.layout.columns,
541 rows: band.layout.rows,
542 cells: band.lastCells,
543 })
544 // a ♥ just left of where the ground begins, on its row: a click on it pets
545 // the animal. No hotkey: a plain Button draws one as `p: ♥`.
546 const pet = petAt(build, band.layout)
547 const clicks = Box({ key: 'companion-clicks', position: 'absolute', top: pet.top, left: pet.left, children: petButton() })
548 // a proposal's `rule`, once typed, opens the rule in MemHub Studio: a
549 // plain Button labelled `rule`, over the word itself, on the bubble's own
550 // background, underlined under the pointer
551 const word = wordOf(band)
552 band.wordKey = keyOfWord(word)
553 const linked = word
554 ? [Box({
555 key: 'rule-word', position: 'absolute', top: word.at.row, left: word.at.col, width: RULE_WORD.length,
556 backgroundColor: BUB_BG_HEX,
557 children: Button({
558 key: 'rule-link', plain: true, label: RULE_WORD, hover: { underline: true }, onPress: opens(word.ask.url),
559 }),
560 })]
561 : []
562 if (!ask || !at) {
563 return Box({ justifyContent: 'flex-end', children: Box({ children: [raster, clicks, ...linked] }) })
564 }
565 if (at !== 'below') {
566 return Box({
567 justifyContent: 'flex-end',
568 children: Box({
569 children: [
570 raster,
571 clicks,
572 ...linked,
573 Box({
574 position: 'absolute', top: at.row, left: at.col, width: at.width,
575 backgroundColor: BUB_BG_HEX, gap: 2, children: controlsFor(ask),
576 }),
577 ],
578 }),
579 })
580 }
581 return Box({
582 flexDirection: 'column',
583 alignItems: 'flex-end',
584 children: [Box({ children: [raster, clicks, ...linked] }), waiting(ask)],
585 })
586 })
587}
588
589/**
590 * What the mod wrote to `$.state` since the band last looked: each new fire
591 * announced once (crossly when it blocked the call), and the proposals handed
592 * to the band when their list changed. Called while drawing, so the reads
593 * subscribe the band and every write draws it again. A read that fails is
594 * nothing new.
595 */
596async function feedFromState($: EngineInterface, band: Band) {
597 const fires = firesOf(await readSafe(() => read($, FIRES)))
598 const { fresh, seen } = freshFires(fires, band.seenFires ?? new Set())
599 const isFirstLook = band.seenFires === null
600 band.seenFires = seen
601 // the first look of this load takes what is there as already said
602 if (!isFirstLook && band.isEnabled) announce(band, fresh.map(saidOfNote))
603 const notes = proposalsOf(await readSafe(() => read($, PROPOSALS)))
604 if (notes !== null && proposalsKey(notes) !== band.proposalsKey) {
605 band.proposalsKey = proposalsKey(notes)
606 void syncProposals($, band, notes)
607 }
608}
609
610/** A `$.state` read that cannot throw: undefined for anything that went wrong. */
611async function readSafe(get: () => Promise<unknown>): Promise<unknown> {
612 try {
613 return await get()
614 } catch {
615 return undefined
616 }
617}
618
619/** The Rulebook lanes the mod has claimed; none when unknown. */
620async function lanesNow($: EngineInterface) {
621 return lanesOf(await readSafe(() => read($, LANES)))
622}
623
624/**
625 * Ask the server for this session's proposed rules — once at a time — merge
626 * its list into `$.state` proposals and hand the result to the band. A poll
627 * that could not ask changes nothing.
628 */
629async function pollProposals($: EngineInterface, band: Band) {
630 if (band.isPolling || !band.isEnabled) return
631 band.isPolling = true
632 try {
633 const session = await sessionIdOf($)
634 if (!session) return
635 const since = await $.clock.now().catch(() => 0)
636 const listed = await listProposed($, band, session, since)
637 if (!listed) return
638 band.env = envName(band.ctx.env)
639 const merged = await update($, PROPOSALS, current => mergedProposals(proposalsOf(current) ?? [], listed.notes, since))
640 .then(proposalsOf, () => null)
641 // no $.state to write to (or the write refused): the server's list is the list
642 const notes = merged ?? listed.notes
643 band.proposalsKey = proposalsKey(notes)
644 await syncProposals($, band, notes)
645 } catch {
646 // companion-upgrades §1.2: never thrown out of a timer
647 } finally {
648 band.isPolling = false
649 }
650}
651
652/** Hand `notes` to the band after any update still running. */
653function syncProposals($: EngineInterface, band: Band, notes: readonly ProposalNote[]): Promise<void> {
654 band.syncing = band.syncing.then(() => applyProposals($, band, notes)).catch(() => undefined)
655 return band.syncing
656}
657
658/**
659 * `notes` is the whole of what is still waiting: an ask on the band it no
660 * longer names (decided in Studio, or by anyone else) comes off, buttons and
661 * any queued saying of it with it; a rule this session has not announced yet
662 * is said and asked. What was announced is kept in the store per session, so
663 * a reload of this module does not say it again (companion-upgrades §3.11).
664 */
665async function applyProposals($: EngineInterface, band: Band, notes: readonly ProposalNote[]) {
666 const waiting = new Set(notes.map(p => p.ruleId))
667 const gone = band.asks.filter(a => !waiting.has(a.ruleId))
668 if (gone.length > 0) {
669 band.asks = band.asks.filter(a => waiting.has(a.ruleId))
670 const unsaid = new Set(gone.map(proposalSaid))
671 band.queue = band.queue.filter(q => !(q.tone === 'proposed' && unsaid.has(q.text)))
672 $.ui.invalidate('ui.render')
673 }
674 if (!band.isEnabled) return
675 const session = await sessionIdOf($)
676 const stored = announcedOf(await $.store.get(STORE_ANNOUNCED).catch(() => undefined))
677 if (session) for (const id of announcedIn(stored, session)) band.announced.add(id)
678 const fresh = notes.filter(p => !band.announced.has(p.ruleId)).map(p => proposedOfNote(p, band.env))
679 if (fresh.length === 0) return
680 for (const p of fresh) band.announced.add(p.ruleId)
681 if (session) {
682 await $.store.set(STORE_ANNOUNCED, withAnnounced(stored, session, fresh.map(p => p.ruleId))).catch(() => undefined)
683 }
684 announce(band, fresh.map(p => ({ text: proposalSaid(p), tone: 'proposed' as const })))
685 band.asks.push(...fresh)
686 for (const p of fresh) void linkOf($, band, p)
687 $.ui.invalidate('ui.render')
688}
689
690// MemHub over `$.http.fetch` (spec §4.11, W12): the proposal poll, Activate /
691// Reject, and where a rule opens in Studio. They used to shell out to
692// `python3 scripts/rule_decide.py` per call; that script stays for the
693// skills, and its requests are mirrored here (feed.ts). Every call settles:
694// a failure is an answer (null, 'no_key', an error Decision), never a throw
695// (companion-upgrades §1.2). These live in this file because `claude plugin
696// validate` follows `$` into no imported function.
697
698/**
699 * The REST base, bearer and Studio origin: the mod's `ctx.api()`, which runs
700 * `rulebook_mod_cli.py api-info` once a session and keeps the answer in module
701 * memory only (a secret: never `$.state` or `$.store`).
702 */
703async function apiFor(band: Band): Promise<Api | undefined> {
704 return band.ctx.api().then(a => (isApi(a) ? a : undefined), () => undefined)
705}
706
707/** Drop the cached credential (after a 401) so the next apiFor() resolves it again. */
708function forgetApi(band: Band) {
709 try {
710 band.ctx.forgetApi()
711 } catch {
712 // a ctx that cannot forget costs one more 401, never a thrown hook
713 }
714}
715
716/** The plugin's folder: `$.plugin.root` may name its `.claude-plugin`. */
717function rootOf($: EngineInterface) {
718 return $.plugin.root.replace(/\/\.claude-plugin\/?$/, '')
719}
720
721let manifestVersion: Promise<string> | undefined
722
723/** plugin_version.request_headers()'s value: the manifest's version, '' when unreadable. */
724function versionOf($: EngineInterface): Promise<string> {
725 manifestVersion ??= $.fs.read(`${rootOf($)}/.claude-plugin/plugin.json`).then(versionOfManifest, () => '')
726 return manifestVersion
727}
728
729type Sent = { status: number; text: string; base: string }
730
731/**
732 * One request to MemHub's REST API with the plugin's key, as rule_decide.py
733 * sends it. A 401 drops the cached key and tries once more with a fresh one.
734 * 'no_key' when there is no key to send; null when no answer came.
735 */
736async function send($: EngineInterface, band: Band, path: string, body?: { method: string; body: string }): Promise<Sent | 'no_key' | null> {
737 for (let attempt = 0; ; attempt += 1) {
738 const api = await apiFor(band).catch(() => undefined)
739 if (!api) return 'no_key'
740 const headers = headersOf(api, await versionOf($), body !== undefined)
741 const base = api.base.replace(/\/+$/, '')
742 const res = await $.http.fetch(`${base}${path}`, { ...body, headers }).catch(() => null)
743 if (!res) return null
744 if (res.status === 401 && attempt === 0) {
745 forgetApi(band)
746 continue
747 }
748 return { status: res.status, text: typeof res.text === 'string' ? res.text : '', base }
749 }
750}
751
752/**
753 * This session's rules still `proposed` as the server lists them, and the
754 * base that answered; null when it could not be asked or answered oddly —
755 * then nothing may be taken off the band.
756 */
757async function listProposed($: EngineInterface, band: Band, session: string, at: number): Promise<{ notes: ProposalNote[]; base: string } | null> {
758 const sent = await send($, band, PROPOSED_PATH)
759 if (!sent || sent === 'no_key' || sent.status < 200 || sent.status >= 300) return null
760 const notes = listedRules(sent.text, session, at)
761 return notes ? { notes, base: sent.base } : null
762}
763
764/** rule_decide.py `decide()`: Studio's own PATCH, and what came of it. */
765async function decideRule($: EngineInterface, band: Band, ruleId: string, action: keyof typeof STATUS_OF): Promise<Decision> {
766 if (!UUID.test(ruleId)) return { outcome: 'error', msg: 'not a rule id' }
767 const sent = await send($, band, rulePath(ruleId), { method: 'PATCH', body: JSON.stringify({ status: STATUS_OF[action] }) })
768 if (sent === 'no_key') return { outcome: 'no_key', msg: 'no stored access key; run /memhub:login' }
769 if (!sent) return { outcome: 'error', msg: 'no connection' }
770 // a second 401, with a freshly resolved key: the key itself is refused
771 if (sent.status === 401) return { outcome: 'no_key', msg: 'access key refused' }
772 return decisionOf(sent.status, sent.text, action)
773}
774
775/** Where the rule (or, with no id, the rulebook) opens in MemHub Studio; '' for nowhere. */
776async function studioUrlFor($: EngineInterface, band: Band, ruleId: string): Promise<string> {
777 const api = await apiFor(band).catch(() => undefined)
778 return api ? studioUrl(api.studio ?? '', ruleId) : ''
779}
780
781/** Take a decided rule out of `$.state` proposals, so no reader asks it again. */
782async function forget($: EngineInterface, band: Band, ruleId: string) {
783 const left = await update($, PROPOSALS, current => (proposalsOf(current) ?? []).filter(p => p.ruleId !== ruleId))
784 .then(proposalsOf, () => null)
785 if (left) band.proposalsKey = proposalsKey(left)
786}
787
788/**
789 * The companion's start, once a session: its saved state, its animal, its
790 * commands and its frame clock. The terminal runs it at session.start; the
791 * desktop, whose session.start says nothing of where it draws, at its first
792 * draw of the band.
793 */
794async function setUp($: EngineInterface, band: Band) {
795 if (band.isSetUp) return
796 band.isSetUp = true
797 band.isEnabled = (await $.store.get(STORE_ENABLED).catch(() => undefined)) !== false
798 const savedScale = await $.store.get(STORE_SCALE).catch(() => undefined)
799 band.scale = SCALES.includes(savedScale as never) ? (savedScale as number) : null
800 band.sessionId = await sessionIdOf($)
801 // The engine has already drawn the band by now — for the fallback animal,
802 // at no saved scale, enabled — and nothing redraws it on its own: switch
803 // the art as well as the name, or the fallback walks in and stays until
804 // the terminal is next resized (a tmux split was how it showed).
805 const animal = await pickFor($, band.sessionId)
806 if (animal !== band.animal || buildFor(animal, band.scale) !== band.build) {
807 switchTo(band, animal)
808 }
809 for (const animal of ANIMALS) {
810 await $.command
811 .register({
812 name: commandOf(animal),
813 description: `The MemHub ${animal.name.toLowerCase()} above the prompt: what the plugin is doing, as it happens`,
814 argumentHint: usageOf(animal),
815 immediate: true,
816 })
817 .catch(() => undefined)
818 }
819 // and redraw whatever the store changed: the animal, the scale's layout,
820 // or a companion turned off, which draws nothing at all
821 $.ui.invalidate('ui.render')
822 if (band.isEnabled) {
823 start($, band)
824 }
825 // proposed rules: once now, then on the clock (and at each turn's end)
826 band.pollTimer ??= $.clock.every(POLL_MS, () => void pollProposals($, band))
827 void pollProposals($, band)
828}
829
830/** The most lines a bubble says where the band is drawn; the terminal's default when undefined. */
831const linesOf = (band: Band) => (band.surface === 'desktop' ? DESKTOP_TEXT_LINES : undefined)
832
833const commandOf = (animal: Animal) => animal.name.toLowerCase()
834
835/** What its bubble introduces it as. */
836const titleOf = (animal: Animal) => animal.title ?? animal.name
837
838/**
839 * The build to draw: the largest whose art is drawn for a pixel no bigger than
840 * the one asked for, and the smallest when nothing is asked — small is the
841 * default, and a bigger pixel is `/<animal> scale <n>`'s to ask for.
842 */
843function buildFor(animal: Animal, scale: number | null): Build {
844 const builds = [...animal.builds].sort((a, b) => a.pixelSize - b.pixelSize)
845 if (scale === null) {
846 return builds[0]!
847 }
848 const fits = builds.filter(b => b.pixelSize <= scale)
849 return (fits[fits.length - 1] ?? builds[0])!
850}
851
852const usageOf = (animal: Animal) =>
853 ['on', 'off', 'auto', 'always', 'random', 'never', 'include', 'scale 1-4',
854 ...(animal.builds[0]?.demo ? ['demo', ...(animal.builds[0]?.demoPoses ?? [])] : [])].join('|')
855
856function wantOf(band: Band): Want {
857 if (band.queue.length > 0) return 'speak'
858 return band.isWorking || band.frame < band.activeUntil || isPetting(band) ? 'look' : 'sleep'
859}
860
861/** A click is waiting to be answered with a pet. */
862const isPetting = (band: Band) => band.frame < band.petUntil
863
864async function sessionIdOf($: EngineInterface): Promise<string> {
865 const id = await $.session.id().catch(() => '')
866 return typeof id === 'string' ? id : ''
867}
868
869async function prefsFor($: EngineInterface): Promise<Prefs> {
870 return prefsOf(
871 await $.store.get(STORE_PIN).catch(() => undefined),
872 await $.store.get(STORE_NEVER).catch(() => undefined),
873 )
874}
875
876/** The animal a session shows: its own pick, the pin, or its id's hash (selection.ts). */
877async function pickFor($: EngineInterface, sessionId: string): Promise<Animal> {
878 const sessions = sessionsOf(await $.store.get(STORE_SESSIONS).catch(() => undefined))
879 const name = pick(ANIMALS.map(a => a.name), sessionId, sessions, await prefsFor($))
880 return ANIMALS.find(a => a.name === name) ?? DEFAULT_ANIMAL
881}
882
883/** Another animal: it starts offscreen, not standing where the last one stood. */
884function switchTo(band: Band, animal: Animal) {
885 band.animal = animal
886 band.mode = 'auto'
887 band.build = buildFor(animal, band.scale)
888 band.seg = segment(band, 'offscreen')
889 band.current = { canvas: [] }
890}
891
892/**
893 * After a /clear or /resume: once the engine reports the new session's id,
894 * pick its animal. Until then (the id not yet changed) it is tried again, by
895 * the next event, and by the frame clock for a while. A command that picks
896 * an animal meanwhile wins.
897 */
898async function repick($: EngineInterface, band: Band) {
899 if (band.isPicking) return
900 band.isPicking = true
901 const gen = band.pickGen
902 try {
903 const id = await sessionIdOf($)
904 if (!id || id === band.sessionId || gen !== band.pickGen) return
905 band.needsRepick = false
906 band.repickUntil = 0
907 band.sessionId = id
908 const pending = band.pendingPick
909 band.pendingPick = null
910 if (pending) {
911 const sessions = sessionsOf(await $.store.get(STORE_SESSIONS).catch(() => undefined))
912 await $.store.set(STORE_SESSIONS, remember(sessions, id, pending)).catch(() => undefined)
913 }
914 const animal = await pickFor($, id)
915 if (gen !== band.pickGen) return
916 if (animal !== band.animal) {
917 switchTo(band, animal)
918 $.ui.invalidate('ui.render')
919 }
920 } finally {
921 band.isPicking = false
922 }
923}
924
925/** A click on the ♥: a pet (see pat). */
926function onClick(band: Band) {
927 if (!band.isEnabled) return
928 pat(band)
929}
930
931/**
932 * What a click does to the animal. Asleep, it wakes to be petted; looking, it
933 * is petted at once; saying a rule, the bubble goes (Got it); presenting a
934 * proposal, nothing — that waits for its answer. Anywhere else the pet waits
935 * until the animal is free, for a few seconds. A demo takes no pets.
936 */
937function pat(band: Band) {
938 if (band.mode !== 'auto') return
939 const name = band.seg.name
940 if (name === 'pet') return
941 if (name === 'speak') {
942 if (band.speakingTone !== 'proposed') band.isHushed = true
943 return
944 }
945 band.petUntil = band.frame + PET_PENDING_FRAMES
946 nudge(band)
947}
948
949/**
950 * A rule's page in the browser, with the platform's opener; one at a time.
951 * Where there is none (a remote shell, a container) nothing happens.
952 */
953async function openUrl($: EngineInterface, band: Band, url: string) {
954 if (band.isOpening) return
955 band.isOpening = true
956 try {
957 await openWith($, url)
958 } finally {
959 band.isOpening = false
960 }
961}
962
963/** The platform's opener: `open`, else `xdg-open`; where neither works, nothing. */
964async function openWith($: EngineInterface, url: string) {
965 for (const opener of ['open', 'xdg-open']) {
966 const run = await $.process.run([opener, url], { timeoutMs: 5_000 }).catch(() => null)
967 if (run && run.exitCode === 0) return
968 }
969}
970
971const saidOfFire = (fire: Fired): Said => ({
972 text: fire.isBlocked ? `Blocked: ${fire.rule}` : `Rule fired: ${fire.rule}`,
973 tone: fire.isBlocked ? 'blocked' : 'advice',
974})
975
976/** A `$.state` fire as the animal says it: the same words as one read off the classic answer. */
977const saidOfNote = (note: FireNote): Said => saidOfFire({ rule: note.rule.replace(/\s+/g, ' ').trim(), isBlocked: note.isBlocked })
978
979/**
980 * Answer the proposal on the buttons with Studio's own PATCH, sent with the
981 * plugin's access key (decideRule: rule_decide.py's request), then say how it
982 * went. The buttons go the moment it is sent, so a second press cannot
983 * answer twice; a rule that left `proposed` leaves `$.state` proposals too.
984 */
985async function decide($: EngineInterface, band: Band, ask: Proposed, action: 'activate' | 'reject') {
986 if (band.isDeciding || band.asks[0] !== ask) return
987 band.isDeciding = true
988 $.ui.invalidate('ui.render')
989 let d: Decision = { outcome: 'error' }
990 try {
991 d = await decideRule($, band, ask.ruleId, action)
992 } catch (err) {
993 d = { outcome: 'error', msg: err instanceof Error ? err.name : 'failed' }
994 } finally {
995 band.isDeciding = false
996 band.asks = band.asks.filter(a => a !== ask)
997 }
998 if (d.outcome === 'active' || d.outcome === 'dismissed' || d.outcome === 'decided' || d.outcome === 'gone') {
999 await forget($, band, ask.ruleId).catch(() => undefined)
1000 }
1001 announce(band, [{ text: decisionSaid(ask, action, d), tone: 'advice' }])
1002 $.ui.invalidate('ui.render')
1003}
1004
1005/** What the demo's proposal is; it answers nothing, so it needs no id. */
1006const DEMO_ASK: Proposed = { title: 'Pin the MCP server when spawning claude -p', ruleId: '', env: '' }
1007
1008/**
1009 * The proposal the buttons answer: the first one waiting, following the
1010 * session; or, in `/goose demo` and `/goose propose`, the demo's own while its
1011 * bubble is a proposal — so the demo shows the whole ask, buttons included.
1012 */
1013function askOf(band: Band): Proposed | undefined {
1014 if (band.mode === 'auto') return band.asks[0]
1015 return band.hasDemoAsk ? DEMO_ASK : undefined
1016}
1017
1018type ButtonsAt = { row: number; col: number; width: number } | 'below' | null
1019
1020/**
1021 * Where a waiting proposal's buttons go: inside its bubble, on the row Got it
1022 * takes, once the text is typed out; on a row under the animal once it has
1023 * said it and is not about to again; and nowhere while it is still being
1024 * presented, so they never show under the animal and then jump into the bubble.
1025 */
1026function buttonsAt(band: Band, build: Build, layout: Layout): ButtonsAt {
1027 if (!askOf(band)) return null
1028 const footer = footerOf(band.current, build, layout)
1029 if (footer) return footer
1030 if (band.mode !== 'auto') return null
1031 const presenting =
1032 band.queue.some(q => q.tone === 'proposed') ||
1033 (band.seg.name === 'speak' && band.speakingTone === 'proposed')
1034 return presenting ? null : 'below'
1035}
1036
1037/**
1038 * The proposal the bubble is saying now, and where its `rule` is, once typed
1039 * out and when there is a Studio page to link to: in the demo its own, else
1040 * the waiting proposal whose words these are.
1041 */
1042function wordOf(band: Band): { ask: Proposed & { url: string }; at: { row: number; col: number } } | null {
1043 const { bubble } = band.current
1044 if (!band.layout || bubble?.tone !== 'proposed') return null
1045 const ask = band.mode === 'auto'
1046 ? band.asks.find(a => fitBubble(proposalSaid(a), linesOf(band)) === bubble.text)
1047 : band.hasDemoAsk ? DEMO_ASK : undefined
1048 if (!ask?.url) return null
1049 const at = wordAt(band.current, band.build, band.layout, RULE_WORD)
1050 return at ? { ask: ask as Proposed & { url: string }, at } : null
1051}
1052
1053const keyOfWord = (word: ReturnType<typeof wordOf>) => (word ? `${word.at.row},${word.at.col},${word.ask.url}` : '')
1054
1055const keyOfButtons = (at: ButtonsAt) => (at === null ? '' : at === 'below' ? 'below' : `${at.row},${at.col}`)
1056
1057/** Short enough for the bubble's footer row, where the buttons were. */
1058const DEMO_SAYS: Record<'activate' | 'reject' | 'later', string> = {
1059 activate: 'demo: would turn it on (admins only)',
1060 reject: 'demo: would dismiss it; never fires',
1061 later: 'demo: hides these; waits in Studio',
1062}
1063/** How long the demo's note stands in for the buttons. */
1064const DEMO_NOTE_FRAMES = 2 * FPS
1065
1066/** A press in the demo says what it would do; nothing reaches MemHub. */
1067function demoPress($: EngineInterface, band: Band, action: keyof typeof DEMO_SAYS) {
1068 band.demoNote = DEMO_SAYS[action]
1069 band.demoNoteUntil = band.frame + DEMO_NOTE_FRAMES
1070 $.ui.invalidate('ui.render')
1071}
1072
1073/**
1074 * Where the rule opens in MemHub Studio — the web app api-info pairs with the
1075 * API the plugin reaches (plugin_onboarding._ORIGINS, which harness_stop's
1076 * rule_url() uses too, so it agrees with the Stop notice's link) — then a
1077 * redraw, so the name turns into a link. '' when it has none.
1078 */
1079async function linkOf($: EngineInterface, band: Band, ask: Proposed) {
1080 if (ask.url !== undefined) return
1081 ask.url = ''
1082 try {
1083 ask.url = await studioUrlFor($, band, ask.ruleId)
1084 } catch {
1085 ask.url = ''
1086 }
1087 $.ui.invalidate('ui.render')
1088}
1089
1090/** Later: the buttons go; the rule stays proposed, in Studio. */
1091function later($: EngineInterface, band: Band) {
1092 band.asks.shift()
1093 $.ui.invalidate('ui.render')
1094}
1095
1096function announce(band: Band, said: readonly Said[]) {
1097 if (said.length === 0) return
1098 band.queue.push(...said)
1099 band.queue.splice(0, Math.max(0, band.queue.length - MAX_QUEUED))
1100 nudge(band)
1101}
1102
1103function segment(band: Band, name: PoseName): Segment {
1104 const { poses } = band.build
1105 band.isHushed = false
1106 band.hushedFrame = ''
1107 switch (name) {
1108 case 'offscreen': return { name, frames: [][Symbol.iterator]() }
1109 case 'enter': return { name, frames: poses.enter() }
1110 case 'sleep': return { name, frames: poses.sleep() }
1111 case 'wake': return { name, frames: poses.wake() }
1112 case 'look': return { name, frames: poses.look() }
1113 case 'rise': return { name, frames: poses.rise() }
1114 case 'leave': return { name, frames: poses.leave() }
1115 case 'demo': return { name, frames: band.build.demo!(band.mode === 'demo' ? null : band.mode) }
1116 case 'pet': {
1117 band.petUntil = 0
1118 band.petAt = band.frame
1119 // a petted animal stays awake a while, then dozes off as after a turn
1120 band.activeUntil = band.frame + LINGER_FRAMES
1121 return { name, frames: poses.pet?.() ?? lookAround(poses.look(), FALLBACK_PET_FRAMES) }
1122 }
1123 case 'speak': {
1124 const said = band.queue.shift()!
1125 band.fires += 1
1126 band.speakingTone = said.tone
1127 return { name, frames: poses.speak(fitBubble(said.text, linesOf(band)), band.fires, said.tone) }
1128 }
1129 }
1130}
1131
1132/** What follows a pose that played to its end. */
1133function after(band: Band, name: PoseName): PoseName {
1134 const want = wantOf(band)
1135 switch (name) {
1136 case 'offscreen': return 'enter'
1137 // enter and sleep both leave the animal asleep; wake is what opens its eyes
1138 case 'enter': return want === 'sleep' ? 'sleep' : 'wake'
1139 case 'wake': return want === 'speak' ? 'rise' : isPetting(band) ? 'pet' : want === 'look' ? 'look' : 'sleep'
1140 case 'pet': return want === 'speak' ? 'rise' : 'look'
1141 case 'rise': return want === 'speak' ? 'speak' : 'leave'
1142 case 'speak': return want === 'speak' ? 'speak' : 'leave'
1143 case 'leave': return 'enter'
1144 default: return name // sleep, look, demo: endless
1145 }
1146}
1147
1148/** Cut in where the animal is only waiting; everything else plays out. */
1149function nudge(band: Band) {
1150 if (band.mode !== 'auto') {
1151 return
1152 }
1153 const want = wantOf(band)
1154 const name = band.seg.name
1155 if (name === 'sleep' && want !== 'sleep') {
1156 band.seg = segment(band, 'wake')
1157 } else if (name === 'look' && want === 'speak') {
1158 band.seg = segment(band, 'rise')
1159 } else if (name === 'look' && isPetting(band)) {
1160 band.seg = segment(band, 'pet')
1161 } else if (name === 'look' && want === 'sleep') {
1162 band.seg = segment(band, 'sleep')
1163 }
1164}
1165
1166function play(band: Band, mode: string) {
1167 band.mode = mode
1168 band.seg = segment(band, mode === 'auto' ? 'leave' : 'demo')
1169}
1170
1171function advance(band: Band): unknown {
1172 for (;;) {
1173 const r = band.seg.frames.next()
1174 if (r.done) {
1175 band.seg = segment(band, after(band, band.seg.name))
1176 continue
1177 }
1178 if (band.isHushed) {
1179 // Got it: the motion plays on, the holds are skipped
1180 const seen = JSON.stringify(r.value)
1181 if (seen === band.hushedFrame) continue
1182 band.hushedFrame = seen
1183 }
1184 return r.value
1185 }
1186}
1187
1188/**
1189 * `n` frames of an endless `look`, the last of them its first again, so the
1190 * look that follows starts where this one ends — the ring's rule for a pet.
1191 */
1192function* lookAround<F>(frames: Iterator<F>, n: number): Generator<F> {
1193 const first = frames.next()
1194 if (first.done) return
1195 yield first.value
1196 for (let i = 1; i < n - 1; i++) {
1197 const r = frames.next()
1198 if (r.done) break
1199 yield r.value
1200 }mod/act.ts 291 lines1// The act side: what the mod DOES with an engine Verdict (spec §4.6, §4.7,
2// §4.15). The engine decides; this file denies, adds context, discloses,
3// feeds the companion and writes the ledger.
4//
5// For each fire:
6// (a) `$.state` `fires` gets a FireNote (newest last, capped at 50) — the
7// companion's feed, replacing its regex over hook output (W7).
8// (b) the disclosure. The person sees the line as a dim transcript row
9// (`$.ui.log`), the mod path's stand-in for the Python hook's
10// `systemMessage`. Gate G2 (spec §8.4) has passed: MemHub-Backend
11// stores a disclosure `system` row as a role="system" message (#1493),
12// so the system row is on (SYSTEM_ROW_DISCLOSURE). With it off (Act's
13// `systemRow` = false, the Python path's behaviour), the model is told
14// to echo the line with rulebook_hook.py's exact
15// `disclosure_instruction` text. With it on, each
16// fire is one transcript `system` row the model never reads,
17// and the echo instruction is dropped for every verdict whose rows
18// were all written; a row that could not be written keeps the echo.
19// The first such fire of a session (and of each compaction) also
20// writes NO_ECHO_NOTE, lifting the posture preamble's own "you MUST
21// disclose" sentence.
22// (c) the ledger: fire rows go to `rulebook_mod_cli.py log` at once (one
23// process per fire, D6); per-call event rows wait in memory for
24// `turn.complete`.
25// (d) health: a lane error shows on the status line and in `$.state`
26// `health`; the next success clears both.
27
28import type { FireNote } from '../types'
29import type { StatusLine } from './claims'
30import type { Ctx, Io } from './ctx'
31import { MOD_CLI } from './ctx'
32import type { Verdict } from './engine'
33
34/**
35 * D5: one `system` row per fire in the transcript (stored as
36 * `{type:'system', subtype:'informational', content:<line>}`), which capture
37 * uploads and the model never reads, in place of the model's echo. On since
38 * gate G2 (spec §8.4) passed: MemHub-Backend stores the row as a
39 * `role="system"` message (#1493, `claude_parts.is_disclosure_record`), so a
40 * stored session keeps the line without the model writing it. The Python
41 * path (Codex, Cursor, the fallback) still asks the model to echo.
42 */
43export const SYSTEM_ROW_DISCLOSURE = true
44
45/**
46 * With the system row on, the model is still carrying the posture preamble's
47 * "When one fires, you MUST disclose it …" (Python's SessionStart serves the
48 * startup session before the mod can claim it, and the mod's own preamble is
49 * byte-identical). Measured live (2.1.293): without this note the model still
50 * opens its reply with `📏 Rule fired:` lines — paraphrased, because it never
51 * saw the exact line. One `user` row per session (again after a compaction)
52 * lifts that instruction; it costs one row, not one per fire.
53 */
54export const NO_ECHO_NOTE =
55 '_MemHub now shows each rule fire to the user and records it in the transcript itself. ' +
56 'Do not write `📏 Rule fired:` or `⛔️ Rule fired:` lines in your replies: that part of ' +
57 "the Rulebook's session-start instruction no longer applies. Follow the rules themselves as before._"
58
59export const FIRES_CAP = 50
60/** Rows kept for a ledger that could not be written, before the oldest drop. */
61const PENDING_CAP = 500
62
63/** rulebook_hook.py `disclosure_instruction(lines)`, byte for byte. */
64export function disclosureInstruction(lines: readonly string[]): string {
65 const quoted = lines.join('\n')
66 return (
67 '\n_Disclose these to the user. Begin your next reply with the following ' +
68 'line(s), verbatim and each on its own line, before anything else — including ' +
69 'before any tool call narration:_\n' +
70 quoted +
71 '\n_This is how the team sees its rules working. Do not paraphrase, do not merge ' +
72 'them into a sentence, and do not omit one because it did not change what you were ' +
73 'going to do — a rule that fired and changed nothing is exactly the rule the team ' +
74 'needs to hear about._'
75 )
76}
77
78const MARKER = /^\s*(📏|⛔️?)\s*Rule fired:[^\S\n]*/u
79
80/** The rule as the line names it: the disclosure line without its marker. */
81export const ruleOfLine = (line: string) => line.replace(MARKER, '').trim()
82const isBlockedLine = (line: string) => /^\s*⛔/u.test(line)
83
84/**
85 * Which ledger rows are events (`log_event`: `event_id` + `kind`) rather than
86 * fires (`log_fires`: `fire_id`). The engine hands both in `Verdict.ledger`.
87 */
88export const isEventRow = (row: Record<string, unknown>) =>
89 typeof row.event_id === 'string' || (typeof row.kind === 'string' && !('fire_id' in row))
90
91export class Act {
92 private events: Record<string, unknown>[] = []
93 private fires: Record<string, unknown>[] = []
94 private notes: Promise<void> = Promise.resolve()
95 /** Verdicts whose every fire is a transcript system row: the model gets no echo instruction. */
96 private disclosed = new WeakSet<Verdict>()
97 /** Sessions whose conversation holds NO_ECHO_NOTE (cleared on compaction). */
98 private noted = new Set<string>()
99 /** A note write in flight, per session: parallel tool calls share it, so a session gets one note. */
100 private noting = new Map<string, Promise<boolean>>()
101 /** The lines of a verdict whose system row failed (its note was written): only those are echoed. */
102 private unwritten = new WeakMap<Verdict, string[]>()
103
104 constructor(
105 private io: Io,
106 private ctx: Ctx,
107 private status: StatusLine,
108 /** D5's transcript system row in place of the echo; SYSTEM_ROW_DISCLOSURE unless a test says. */
109 private systemRow: boolean = SYSTEM_ROW_DISCLOSURE,
110 ) {}
111
112 /** (a)–(c) for one verdict. Never throws: acting must not cost the call. */
113 async record(v: Verdict | undefined, sessionId: string): Promise<void> {
114 try {
115 await this.recordOnce(v, sessionId)
116 } catch (err) {
117 // Anything unforeseen (a synchronous `$` call that throws) is a health
118 // line, never the call's failure.
119 this.unhealthy('ledger', err)
120 }
121 }
122
123 private async recordOnce(v: Verdict | undefined, sessionId: string): Promise<void> {
124 if (!v) return
125 const at = await this.io.now().catch(() => 0)
126 if (v.fires.length) {
127 const notes: FireNote[] = v.fires.map(f => ({
128 ruleId: f.ruleId,
129 line: f.line,
130 rule: ruleOfLine(f.line) || f.text,
131 isBlocked: isBlockedLine(f.line),
132 at,
133 }))
134 await this.addNotes(notes)
135 const noted = this.systemRow && (await this.noteOnce(sessionId))
136 let written = noted
137 const failed: string[] = []
138 for (const f of v.fires) {
139 // `$.ui.log` is synchronous and may throw: the line is best effort.
140 try {
141 this.io.log(f.line)
142 } catch {
143 // the transcript copy (the system row, or the model's echo) remains
144 }
145 if (!this.systemRow) continue
146 try {
147 await this.io.append('system', f.line)
148 } catch (err) {
149 // No transcript copy for this fire: the verdict keeps the echo
150 // instruction, so the line is not lost, and the person is told.
151 written = false
152 failed.push(f.line)
153 this.unhealthy('disclosure', err)
154 }
155 }
156 if (written) {
157 this.disclosed.add(v)
158 this.healthy('disclosure')
159 } else if (noted && failed.length) {
160 // The note is in place, so the model echoes only what we asked it to:
161 // just the lines with no row, never one the transcript already holds.
162 this.unwritten.set(v, failed)
163 }
164 }
165 for (const row of v.ledger) (isEventRow(row) ? this.events : this.fires).push(row)
166 // One process per fire, never per call: events alone wait for the turn's end.
167 if (this.fires.length) await this.flush(sessionId)
168 }
169
170 /**
171 * The text the model reads for a verdict: its context, then the echo
172 * instruction — unless `record` wrote every fire as a transcript system row.
173 */
174 modelText(v: Verdict | undefined): string | undefined {
175 if (!v) return undefined
176 const lines = [...v.context]
177 if (v.fires.length && !this.disclosed.has(v)) {
178 lines.push(disclosureInstruction(this.unwritten.get(v) ?? v.fires.map(f => f.line)))
179 }
180 const text = lines.join('\n')
181 return text.trim() ? text : undefined
182 }
183
184 /**
185 * A deny carries no `context` (ToolCallResult's deny arm), so the model's
186 * whole copy rides the reason: the gate's own text, then the context and
187 * (unless the fires are system rows) the echo instruction the Python hook
188 * would have put in additionalContext.
189 */
190 denyText(v: Verdict): string {
191 return [v.deny ?? '', this.modelText(v)].filter(Boolean).join('\n')
192 }
193
194 /**
195 * NO_ECHO_NOTE into the session once. False when it could not be written:
196 * the preamble's disclosure instruction then stands, so the verdict keeps
197 * the verbatim echo instruction rather than leave the model to paraphrase.
198 */
199 private async noteOnce(sessionId: string): Promise<boolean> {
200 if (this.noted.has(sessionId)) return true
201 // Parallel tool calls each record a verdict: they share the one write in
202 // flight, or a session would get the note once per concurrent call.
203 const inFlight = this.noting.get(sessionId)
204 if (inFlight) return inFlight
205 const write = (async () => {
206 try {
207 await this.io.append('user', NO_ECHO_NOTE)
208 this.noted.add(sessionId)
209 return true
210 } catch (err) {
211 this.unhealthy('disclosure', err)
212 return false
213 } finally {
214 this.noting.delete(sessionId)
215 }
216 })()
217 this.noting.set(sessionId, write)
218 return write
219 }
220
221 /** A compaction summarised the note away with the preamble: the next fire writes it again. */
222 compacted(sessionId: string): void {
223 this.noted.delete(sessionId)
224 }
225
226 /** Writes every waiting ledger row through the Python ledger (turn.complete, or a fire). */
227 async flush(sessionId: string): Promise<void> {
228 if (!this.fires.length && !this.events.length) return
229 const fires = this.fires
230 const events = this.events
231 this.fires = []
232 this.events = []
233 try {
234 const r = await this.io.run(['python3', `${this.ctx.root}/${MOD_CLI}`, 'log', '--env', this.ctx.env], {
235 stdin: JSON.stringify({ session: sessionId, fires, events }),
236 timeoutMs: 10_000,
237 })
238 if (r.exitCode !== 0) throw new Error(`ledger log exited ${r.exitCode}`)
239 this.healthy('ledger')
240 } catch (err) {
241 // Kept for the next flush; bounded so a broken ledger cannot grow without end.
242 this.fires = [...fires, ...this.fires].slice(-PENDING_CAP)
243 this.events = [...events, ...this.events].slice(-PENDING_CAP)
244 this.unhealthy('ledger', err)
245 }
246 }
247
248 private failing = new Set<string>()
249
250 /** (d) A lane (or the ledger) hit an error the person should know of. */
251 unhealthy(what: string, err: unknown): void {
252 this.failing.add(what)
253 const why = err instanceof Error ? err.message : String(err)
254 const line = `MemHub: ${what} — ${why}`.replace(/\s+/g, ' ').slice(0, 200)
255 this.show(line)
256 }
257
258 /** (d) The next success clears it. */
259 healthy(what: string): void {
260 if (!this.failing.delete(what) || this.failing.size) return
261 this.show(undefined)
262 }
263
264 /** The health line on the status line and in `$.state`. Never throws: it runs in failure paths. */
265 private show(line: string | undefined): void {
266 try {
267 this.status.set('health', line)
268 } catch {
269 // `$.ui.status` is synchronous; a refused line is not the call's failure
270 }
271 try {
272 void this.io.setState('health', line ?? '').catch(() => undefined)
273 } catch {
274 // as above
275 }
276 }
277
278 private addNotes(notes: FireNote[]): Promise<void> {
279 // One read-modify-write at a time: parallel tool calls fire together.
280 this.notes = this.notes.then(async () => {
281 try {
282 const { value = [] } = await this.io.getState('fires')
283 await this.io.setState('fires', [...value, ...notes].slice(-FIRES_CAP))
284 } catch {
285 // the companion's feed is best effort
286 }
287 })
288 return this.notes
289 }
290}
291mod/book.ts 483 lines1// The rule book on the mod path (spec §4.1): load the cached book at
2// session.start, keep it fresh with `$.clock.every`, and hand the rows to the
3// engine. The book file is the one rulebook_hook.py reads and writes —
4// `{"etag", "fetched_at", "rules"}` at the path `rulebook_mod_cli.py paths`
5// names — so the Python fallback is never staler than the mod.
6//
7// Mirrors rulebook_hook.py `fetch_book` (GET /v1/team/rulebook/rules
8// ?view=hook&repo=<repo>&hook_version=<v> with If-None-Match; no `status=`)
9// and `_norm_rules` (to_hook_rule per row, first id wins). Replaces the
10// detached `fetch` child `maybe_refresh` spawns (W4).
11
12import type { Claims, StatusLine } from './claims'
13import type { Ctx, Io } from './ctx'
14import { lastJson, MOD_CLI } from './ctx'
15import type { Engine, HookRule } from './engine'
16import { toHookRule as shapeRow, versionTuple } from './engine/rules'
17
18/**
19 * One served row → the flat hook rule (engine/rules.ts `toHookRule`), shaped
20 * for this build's own version so a `min_hook_version` row degrades exactly
21 * as it does in the Python hook beside it. No forward-test claim here
22 * (`activeBase` false): /memhub:create-rule's private-base forward test is
23 * served by the Python hook, which reads the claim.
24 */
25export const hookRuleFor = (hookVersion: string | undefined) => (row: Record<string, unknown>): HookRule | null =>
26 shapeRow(row, { hookVersion: versionTuple(hookVersion ?? null), activeBase: false })
27
28export const toHookRule = hookRuleFor(undefined)
29
30export const API_PATH = '/v1/team/rulebook'
31export const REFRESH_MS = 60_000
32/** Consecutive refresh failures before the status line says so (a blip is not news). */
33const REFRESH_FAILURES_SHOWN = 3
34/**
35 * Consecutive refresh failures after which the mod gives every lane back to
36 * Python for the session — once its book is also older than Python's own
37 * staleness window (rulebook_hook.py `REFRESH_AFTER_S`), so Python, whose
38 * urllib takes a different road than `$.http.fetch`, would refresh it.
39 */
40export const RELEASE_AFTER_FAILURES = 5
41/** rulebook_hook.py `REFRESH_AFTER_S` (60 s): Python refreshes a book older than this. */
42export const PYTHON_REFRESH_AFTER_MS = 60_000
43
44export const FETCH_REFUSED = "MemHub rules: your organization's web-fetch policy refuses the mod — the command hooks serve the rules"
45export const REFRESH_GAVE_UP = 'MemHub rules: the mod could not refresh the rules — the command hooks serve them'
46
47/**
48 * Whether a `$.http.fetch` rejection is a refusal, never a network blip.
49 *
50 * The engine refuses before any request leaves with
51 * `<plugin>: $.http.fetch: refused: <why>` — the organization's web-fetch
52 * policy (`allow_web_fetch`: "Network access from plugins …"),
53 * CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, a confined eval — and a hook's
54 * `{ deny }` on `http.fetch` rejects as `<plugin>: $.http.fetch: <deny>`. A
55 * request that went out and failed reads `$.http.fetch(<url>) failed: …` or
56 * `… aborted: …`, with the URL in parentheses. (Claude Code 2.1.292; the
57 * d.ts says only "unless the organization's web-fetch policy refuses it".)
58 * A refusal does not heal by retrying, so it is acted on at once.
59 */
60export function isFetchRefused(err: unknown): boolean {
61 const msg = err instanceof Error ? err.message : String(err)
62 return /\$\.http\.fetch: /.test(msg)
63}
64
65/** A `fetched_at` Python wrote (microseconds, an offset) as epoch ms; undefined when unreadable. */
66export function msOfIso(iso: string | null | undefined): number | undefined {
67 if (typeof iso !== 'string') return undefined
68 const ms = Date.parse(iso.replace(/(\.\d{3})\d+/, '$1'))
69 return Number.isFinite(ms) ? ms : undefined
70}
71
72export type BookFile = { etag?: string | null; fetched_at?: string | null; rules: Record<string, unknown>[] }
73export type Paths = { book: string; repo: string; ledger?: string }
74
75/** The cached book, as `load_book` accepts it: a dict whose `rules` is a list. */
76export function parseBook(text: string): BookFile | undefined {
77 try {
78 const b = JSON.parse(text) as unknown
79 if (b && typeof b === 'object' && !Array.isArray(b) && Array.isArray((b as BookFile).rules)) return b as BookFile
80 } catch {
81 // unreadable is no book, as in Python
82 }
83 return undefined
84}
85
86/** `_norm_rules`: each row through toHookRule, unrunnable rows dropped, the first of an id kept. */
87export function shapeRules(
88 rows: readonly unknown[],
89 toHook: (row: Record<string, unknown>) => HookRule | null = toHookRule,
90): HookRule[] {
91 const out: HookRule[] = []
92 const seen = new Set<string>()
93 for (const row of rows) {
94 if (!row || typeof row !== 'object') continue
95 const r = toHook(row as Record<string, unknown>)
96 if (r && !seen.has(r.id)) {
97 seen.add(r.id)
98 out.push(r)
99 }
100 }
101 return out
102}
103
104/** `_now()`: ISO with microseconds and an explicit offset, which every Python `fromisoformat` reads. */
105export function isoOf(ms: number): string {
106 return new Date(ms).toISOString().replace(/\.(\d{3})Z$/, '.$1000+00:00')
107}
108
109/** `urllib.parse.quote(s, safe="")`: everything but letters, digits and `_.-~` escaped. */
110export function quoteAll(s: string): string {
111 return encodeURIComponent(s).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
112}
113
114/** `require_secure`: a credential rides https, or loopback http only. */
115export function isSecure(url: string): boolean {
116 const m = /^([a-z][a-z0-9+.-]*):\/\/(\[[^\]]*\]|[^/:?#]*)/i.exec(url)
117 if (!m) return false
118 if (m[1]!.toLowerCase() === 'https') return true
119 const host = m[2]!.toLowerCase().replace(/^\[|\]$/g, '')
120 return m[1]!.toLowerCase() === 'http' && (host === 'localhost' || host === '127.0.0.1' || host === '::1')
121}
122
123/** `rulebook_mod_cli.py paths --env <env> --cwd <dir>`: the book file, the repo and the ledger dir. */
124export async function pathsFor(io: Io, ctx: Ctx, cwd: string): Promise<Paths | undefined> {
125 const r = await io.run(['python3', `${ctx.root}/${MOD_CLI}`, 'paths', '--env', ctx.env, '--cwd', cwd], { timeoutMs: 10_000 })
126 if (r.exitCode !== 0) throw new Error(`paths exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`)
127 const got = lastJson(r.stdout) as Record<string, unknown> | undefined
128 if (!got || typeof got !== 'object') throw new Error('paths printed no JSON')
129 const book = got.book ?? got.book_path
130 const repo = got.repo
131 const ledger = got.ledger ?? got.ledger_dir
132 // No repo (not a git checkout, or no remote): no book to serve, nothing to claim.
133 if (typeof repo !== 'string' || !repo || typeof book !== 'string' || !book) return undefined
134 return { book, repo, ledger: typeof ledger === 'string' ? ledger : undefined }
135}
136
137export type RefreshOutcome = 'updated' | 'unchanged' | 'upgrade' | 'skipped' | 'failed'
138
139export class Book {
140 repo: string | undefined
141 path: string | undefined
142 private etag: string | undefined
143 private timer: { cancel(): void } | undefined
144 private inFlight = false
145 private failures = 0
146 private stopped = false
147 /** When the book the engine holds was last known current (epoch ms). */
148 private freshAt: number | undefined
149
150 constructor(
151 private io: Io,
152 private ctx: Ctx,
153 private engine: Engine,
154 private claims: Claims,
155 private status: StatusLine,
156 private opts: { hookVersion?: string; toHook?: (row: Record<string, unknown>) => HookRule | null } = {},
157 ) {}
158
159 /**
160 * At session.start: find the book, read it, hand it to the engine. A
161 * missing cache is fetched once, here. Resolves true once the engine holds
162 * a book (then the lanes may be claimed), false when there is none to hold
163 * (signed out with no cache, no repo): Python then serves, with no book
164 * either. Rejects on a real failure (the caller releases every lane).
165 */
166 async load(cwd: string): Promise<boolean> {
167 const paths = await pathsFor(this.io, this.ctx, cwd)
168 if (!paths) return false
169 this.repo = paths.repo
170 this.path = paths.book
171 // A recorded upgrade notice suspends the cached rules (rulebook_hook
172 // `upgrade_status`); only Python can validate and clear it, so it serves.
173 if (await this.io.readFile(`${paths.book}.upgrade`).then(() => true, () => false)) return false
174 const text = await this.io.readFile(paths.book).catch(() => undefined)
175 const cached = text === undefined ? undefined : parseBook(text)
176 if (cached) {
177 this.etag = cached.etag ?? undefined
178 this.freshAt = msOfIso(cached.fetched_at)
179 await this.hand(cached.rules)
180 return true
181 }
182 return (await this.refresh()) === 'updated'
183 }
184
185 /** Every minute until stopped. A tick never overlaps the one before it. */
186 start(): void {
187 if (this.timer || this.stopped) return
188 this.timer = this.io.every(REFRESH_MS, () => {
189 void this.refresh()
190 })
191 }
192
193 stop(): void {
194 this.stopped = true
195 this.timer?.cancel()
196 this.timer = undefined
197 }
198
199 /** One GET with If-None-Match: 200 → write the file + setBook; 304 → nothing; 426 → stop serving. */
200 async refresh(): Promise<RefreshOutcome> {
201 if (this.inFlight || this.stopped || !this.repo || !this.path) return 'skipped'
202 this.inFlight = true
203 try {
204 return await this.fetchOnce(true)
205 } catch (err) {
206 await this.failed(err)
207 return 'failed'
208 } finally {
209 this.inFlight = false
210 }
211 }
212
213 private async fetchOnce(retryOn401: boolean): Promise<RefreshOutcome> {
214 const api = await this.ctx.api()
215 if (!api) return 'skipped'
216 if (!isSecure(api.base)) throw new Error('refusing to send credentials over cleartext')
217 let q = `view=hook&repo=${quoteAll(this.repo!)}`
218 if (this.opts.hookVersion) q += `&hook_version=${this.opts.hookVersion}`
219 const headers: Record<string, string> = { Authorization: `Bearer ${api.bearer}`, Accept: 'application/json' }
220 if (this.opts.hookVersion) headers['X-MemHub-Plugin-Version'] = this.opts.hookVersion
221 if (this.etag) headers['If-None-Match'] = this.etag
222 const reply = await this.io.fetch(`${api.base}${API_PATH}/rules?${q}`, { method: 'GET', headers })
223 if (reply.status === 304) {
224 this.freshAt = await this.io.now()
225 this.succeeded()
226 return 'unchanged'
227 }
228 if (reply.status === 401 && retryOn401) {
229 this.ctx.forgetApi()
230 return this.fetchOnce(false)
231 }
232 if (reply.status === 426) {
233 await this.upgradeRequired(reply.text)
234 return 'upgrade'
235 }
236 if (reply.status !== 200) throw new Error(`rules fetch HTTP ${reply.status}`)
237 const rules = rulesOf(reply.text)
238 if (!rules) throw new Error('rules fetch: unexpected reply shape')
239 const etag = reply.headers['etag']
240 const now = await this.io.now()
241 const file: BookFile = { etag: etag ?? null, fetched_at: isoOf(now), rules }
242 // The engine first: what the mod serves must never lag the file Python reads.
243 await this.hand(rules)
244 this.etag = etag
245 this.freshAt = now
246 await this.io.writeFile(this.path!, JSON.stringify(file))
247 this.succeeded()
248 return 'updated'
249 }
250
251 private async hand(rows: readonly unknown[]): Promise<void> {
252 const rules = shapeRules(rows, this.opts.toHook ?? hookRuleFor(this.opts.hookVersion))
253 this.engine.setBook(rules, { repo: this.repo!, fetchedAt: await this.io.now() })
254 }
255
256 /**
257 * The server refuses this plugin version: cached rules are suspended. The
258 * mod stops serving every lane, and Python's own fetch records the upgrade
259 * notice beside the book (`<book>.upgrade`), which is what makes the Python
260 * lanes suspend the cached rules and tell the agent.
261 */
262 private async upgradeRequired(body: string): Promise<void> {
263 this.stop()
264 let minimum: string | undefined
265 try {
266 const p = JSON.parse(body) as { data?: { minimum_version?: unknown } }
267 if (typeof p?.data?.minimum_version === 'string' && /^\d{1,6}\.\d{1,6}\.\d{1,6}$/.test(p.data.minimum_version)) {
268 minimum = p.data.minimum_version
269 }
270 } catch {
271 // the status line says it without the number
272 }
273 this.status.set(
274 'upgrade',
275 `MemHub: update the plugin${minimum ? ` (minimum ${minimum})` : ''} — team rules are paused`,
276 )
277 await this.claims.release()
278 await this.io
279 .run(['python3', `${this.ctx.root}/scripts/rulebook_hook.py`, 'fetch', this.repo!], { timeoutMs: 15_000 })
280 .catch(() => undefined)
281 }
282
283 private succeeded(): void {
284 this.failures = 0
285 this.status.set('refresh', undefined)
286 }
287
288 /**
289 * A refresh failed. A refusal (the org's web-fetch policy, a hook's deny)
290 * gives every lane back to Python at once: the mod would serve a stale book
291 * forever and the judge fails the same way, while Python's own fetch is not
292 * subject to that policy. A network blip is retried; after
293 * RELEASE_AFTER_FAILURES in a row with a book older than Python's staleness
294 * window, the lanes go back to Python too.
295 */
296 private async failed(err: unknown): Promise<void> {
297 if (isFetchRefused(err)) return this.giveUp(FETCH_REFUSED)
298 this.failures += 1
299 if (this.failures >= RELEASE_AFTER_FAILURES) {
300 const age = this.freshAt === undefined ? Infinity : (await this.io.now()) - this.freshAt
301 if (age > PYTHON_REFRESH_AFTER_MS) return this.giveUp(REFRESH_GAVE_UP)
302 }
303 if (this.failures >= REFRESH_FAILURES_SHOWN) {
304 const why = err instanceof Error ? err.message : String(err)
305 this.status.set('refresh', `MemHub: team rules not refreshed — ${why}`.slice(0, 200))
306 }
307 }
308
309 /** Stop refreshing and release every lane for the session, saying why on the status line. */
310 private async giveUp(why: string): Promise<void> {
311 this.stop()
312 this.status.set('refresh', undefined)
313 this.status.set('claims', why)
314 await this.claims.release().catch(() => undefined)
315 }
316}
317
318/** `{"code":0,"msg":"ok","data":{"rules":[…]}}` or the bare `{"rules":[…]}`; undefined otherwise. */
319function rulesOf(text: string): Record<string, unknown>[] | undefined {
320 try {
321 let p = JSON.parse(text) as Record<string, unknown> | null
322 if (p && typeof p === 'object' && 'code' in p) {
323 if (p.code !== 0) return undefined
324 if ('data' in p) p = p.data as Record<string, unknown> | null
325 }
326 const rules = p && typeof p === 'object' ? (p as { rules?: unknown }).rules : undefined
327 return Array.isArray(rules) ? (rules as Record<string, unknown>[]) : undefined
328 } catch {
329 return undefined
330 }
331}
332
333// ── the remote switch (ENG-1206) ────────────────────────────────────────────
334//
335// `GET /v1/plugin/compatibility` answers `"mod_lanes": true|false`: false
336// tells every mod to leave the Rulebook lanes to the Python command hooks. A
337// missing field means true, so an older backend changes nothing. Mirrors
338// plugin_compatibility.py `check`: the endpoint at the REST base's origin, the
339// bearer, `Accept: application/json` and `X-MemHub-Plugin-Version`
340// (mcp_http.rest + plugin_version.request_headers), the `{code, data}`
341// envelope unwrapped.
342//
343// Read at boot before any lane is claimed, then every SWITCH_MS while lanes
344// are claimed. Each request races COMPAT_TIMEOUT_MS (Python's `timeout=2`;
345// `$.http.fetch` has none of its own). Fail direction: a read that does not
346// answer (network, timeout, 401, a non-200, a refused `$.http.fetch`, an
347// unexpected shape) changes nothing —
348// a blip never flips lanes; a refused fetch is already acted on by the book's
349// own refresh. A false is final for the session: lanes released then are
350// never re-claimed by this module, even if the switch reads true again (a hot
351// reload is a fresh module and reads the switch afresh at its own boot).
352
353export const COMPAT_PATH = '/v1/plugin/compatibility'
354export const SWITCH_MS = 5 * 60_000
355/** plugin_compatibility.check's `timeout=2`. */
356export const COMPAT_TIMEOUT_MS = 2_000
357export const SWITCHED_OFF = 'MemHub rules: served by the command hooks (remote switch)'
358
359/** on: the mod may serve (true, or the field absent); off: give every lane to Python; unknown: keep the current state. */
360export type SwitchRead = 'on' | 'off' | 'unknown'
361
362/** `scheme://netloc` of a URL, as plugin_compatibility.py builds the endpoint; undefined when unparseable. */
363export function originOf(url: string): string | undefined {
364 const m = /^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)/i.exec(url)
365 return m ? m[1] : undefined
366}
367
368/** The compatibility reply's `mod_lanes`, as a switch read. */
369export function modLanesOf(text: string): SwitchRead {
370 try {
371 let p = JSON.parse(text) as unknown
372 if (p && typeof p === 'object' && !Array.isArray(p) && 'code' in p) {
373 const env = p as { code?: unknown; data?: unknown }
374 if (env.code !== 0) return 'unknown'
375 if ('data' in env) p = env.data
376 }
377 if (!p || typeof p !== 'object' || Array.isArray(p)) return 'unknown'
378 const v = (p as { mod_lanes?: unknown }).mod_lanes
379 if (v === false) return 'off'
380 if (v === true || v === undefined) return 'on'
381 return 'unknown'
382 } catch {
383 return 'unknown'
384 }
385}
386
387export class RemoteSwitch {
388 /** The switch read false this session: lanes are released and stay so. */
389 off = false
390 private timer: { cancel(): void } | undefined
391 private inFlight = false
392
393 constructor(
394 private io: Io,
395 private ctx: Ctx,
396 private claims: Claims,
397 private status: StatusLine,
398 private opts: { hookVersion?: string; onOff?: () => void } = {},
399 ) {}
400
401 /** One read of the switch; never rejects (a failure is 'unknown'). */
402 async read(): Promise<SwitchRead> {
403 try {
404 return await this.readOnce(true)
405 } catch (err) {
406 this.io.debug(`MemHub rules: remote switch not read — ${err instanceof Error ? err.message : String(err)}`)
407 return 'unknown'
408 }
409 }
410
411 /** At boot, before any claim: false when the switch is off (every lane then stays Python's). */
412 async allowsClaim(): Promise<boolean> {
413 if ((await this.read()) !== 'off') return true
414 await this.turnOff()
415 return false
416 }
417
418 /** Re-read every SWITCH_MS while the module lives and the switch is not off (`turnOff` cancels the timer). */
419 start(): void {
420 if (this.timer || this.off) return
421 this.timer = this.io.every(SWITCH_MS, () => {
422 void this.poll()
423 })
424 }
425
426 /**
427 * One read: an off releases every lane for the session; on or unknown
428 * changes nothing — in particular an on after an off claims nothing back.
429 * Overlapping reads are skipped; a hung one is bounded by COMPAT_TIMEOUT_MS,
430 * so the next tick reads again.
431 */
432 async poll(): Promise<void> {
433 if (this.inFlight) return
434 this.inFlight = true
435 try {
436 if ((await this.read()) === 'off') await this.turnOff()
437 } finally {
438 this.inFlight = false
439 }
440 }
441
442 private async readOnce(retryOn401: boolean): Promise<SwitchRead> {
443 const api = await this.ctx.api()
444 if (!api) return 'unknown'
445 if (!isSecure(api.base)) throw new Error('refusing to send credentials over cleartext')
446 const origin = originOf(api.base)
447 if (!origin) throw new Error('no origin in the API base')
448 // Always sent, as mcp_http.rest does: plugin_version.py says 'unknown'
449 // when the manifest has no readable x.y.z, and so does this.
450 const headers: Record<string, string> = {
451 Authorization: `Bearer ${api.bearer}`,
452 Accept: 'application/json',
453 'X-MemHub-Plugin-Version': this.opts.hookVersion ?? 'unknown',
454 }
455 const timedOut = this.io.sleep(COMPAT_TIMEOUT_MS).then(() => {
456 throw new Error(`compatibility: no answer in ${COMPAT_TIMEOUT_MS} ms`)
457 })
458 const reply = await Promise.race([this.io.fetch(`${origin}${COMPAT_PATH}`, { method: 'GET', headers }), timedOut])
459 if (reply.status === 401 && retryOn401) {
460 this.ctx.forgetApi()
461 return this.readOnce(false)
462 }
463 if (reply.status !== 200) throw new Error(`compatibility HTTP ${reply.status}`)
464 return modLanesOf(reply.text)
465 }
466
467 /** Release every lane for the rest of the session and say so; the renewal and recovery paths never bring one back (claims.ts `lost`). */
468 private async turnOff(): Promise<void> {
469 if (this.off) return
470 this.off = true
471 this.timer?.cancel()
472 this.timer = undefined
473 this.status.set('claims', SWITCHED_OFF)
474 this.opts.onOff?.()
475 // A failed write already serves nothing and tries to unset the variable
476 // (claims.ts `writeFailed`); the variable's lease lapses within LEASE_S
477 // if even that failed. Said in the debug log, never on screen.
478 await this.claims.release().catch(err => {
479 this.io.debug(`MemHub rules: remote switch release failed — ${err instanceof Error ? err.message : String(err)}`)
480 })
481 }
482}
483mod/claims.ts 324 lines1// Lane claims (spec §3.3, D2): which Rulebook lanes the mod serves, told to
2// the Python hooks through ONE env var per environment —
3// `MEMHUB_MOD_LANES_STAGING` / `MEMHUB_MOD_LANES_PROD` =
4// "<session_id>:<expires_epoch_s>:<pid>:pre,post,prompt,session".
5// `hooks/hook_entry.py` (scripts/mod_lanes.py) skips a lane its own
6// environment's variable lists ONLY while the lease is live, the pid is its
7// own `CLAUDE_PID` (where both are known), and the stamp is its payload's
8// `session_id` (or the payload is a call served here for another session).
9//
10// A claim must never outlive or escape the module that made it, or a gate is
11// lost: nothing evaluates it.
12// * Escape: the variable is inherited by every child process. A `claude`
13// started from a Bash call or a skill sees it, and if that child's mod did
14// not load (allowManagedModsOnly, --safe-mode, disableAllHooks, an older
15// build, a load error), an inherited claim would leave nothing evaluating
16// the rules. The child's hooks carry its own pid and session id. Subagent
17// calls carry the parent's session id, so they stay ours. A /clear or
18// resume changes the id without a session.start: `restamp()` (at the next
19// prompt) writes the new one; in between Python serves.
20// * Outlive: `$.env` writes go straight to the process environment and the
21// engine does not undo them when the module crashes, fails a hot reload
22// or unloads. So the claim is a LEASE: every write sets the expiry to now
23// + LEASE_S, and a `$.clock.every` timer renews it every RENEW_MS. Timers
24// die with the module, so a dead module's claim lapses by itself within
25// LEASE_S and Python serves.
26//
27// The invariant this file exists for: a gate is never lost. It is doubled
28// (the mod and Python both evaluate one call) only in two brief overlaps,
29// accepted because the alternative is a gap:
30// * the lease hand-back: the mod serves up to LEASE_LATE_MS past its own
31// lease, while Python already serves the lapsed lane;
32// * parallel calls: a call that passed `has(lane)` before another call's
33// failure took the lane out of the variable is evaluated by the mod, and
34// by the Python hook at its `next`, which now sees the lane unclaimed.
35// Its parts:
36// * Claim late: a lane is listed only once the mod can serve it (book loaded).
37// * A lane the variable does not list is served by Python alone: every lane
38// hook asks `has(lane)` before it evaluates anything (lanes.ts).
39// * A write that fails leaves the variable as it was, still listing lanes
40// the module may have dropped: the module stops serving at once (no live
41// lease) and tries to unset the variable, so Python serves; if even that
42// fails, the variable's own lease lapses within LEASE_S.
43// * Release on failure: a failing pre/prompt lane takes itself out of the
44// variable BEFORE it calls `next(e)`, so the Python hook beneath (which the
45// engine starts at the last mod's `next`, spec §1.2) serves that very call.
46// The first failure is forgiven once the call settles (`recover`); the
47// second, or any failure at load, releases the lane for the session and
48// says so on the status line.
49// * The fail→recover window: `recover` runs only after that call's `next`
50// settles, so the lane stays paused for the whole of `next` (for a long
51// Bash call, minutes). Every call that starts in the window finds the
52// lane unclaimed and is evaluated by Python's hook alone: single
53// evaluation, never a gap, but the mod serves nothing on that lane
54// meanwhile. (The paused lane is pre or prompt; a claimed post lane keeps
55// running in the mod for those calls, and Python's post hook skips it.)
56//
57// Module memory holds the truth; `$.state` `lanes` mirrors it for the
58// companion and for a hot reload (which re-claims at its own session.start).
59
60import type { LaneName } from '../types'
61import type { Env, Io } from './ctx'
62
63export const LANES: readonly LaneName[] = ['pre', 'post', 'prompt', 'session']
64export const FELL_BACK = 'MemHub rules: fell back to the command hooks'
65/** Failures a lane survives in a session; the next one releases it. */
66const MAX_FAILURES = 2
67
68/**
69 * The lease. Renewed every 30 s, valid for 90 s: a live module renews twice
70 * before its lease could lapse, so one late or refused tick (a busy event
71 * loop, a slow `$.env` write) never hands a lane to Python while the mod still
72 * serves it; a dead module's claim lapses within 90 s, which bounds how long
73 * gates can go unevaluated after a crash, a failed reload or an unload. A
74 * shorter lease buys a shorter gap at the price of a renewal (two `$` calls)
75 * more often; 90 s is under two of the book's 60 s refresh periods.
76 */
77export const LEASE_S = 90
78export const RENEW_MS = 30_000
79/**
80 * How long past its own lease the mod keeps serving. Python checks the lease
81 * after the mod has evaluated pre and prompt (its hook runs at the mod's
82 * `next`), but before the mod evaluates post; serving a little past the
83 * expiry makes the hand-back an overlap (both evaluate) and never a gap.
84 */
85export const LEASE_LATE_MS = 30_000
86
87/**
88 * This plugin's one status line (`$.ui.status` is one per plugin), shared by
89 * the parts that want it. The most important message shows: an upgrade notice
90 * over a fallback over a lane's health line over a stale book.
91 */
92export class StatusLine {
93 private slots: { upgrade?: string; claims?: string; health?: string; refresh?: string } = {}
94 private shown: string | undefined
95 constructor(private io: Io) {}
96 set(slot: 'upgrade' | 'claims' | 'health' | 'refresh', text: string | undefined) {
97 this.slots[slot] = text
98 const next = this.slots.upgrade ?? this.slots.claims ?? this.slots.health ?? this.slots.refresh
99 if (next === this.shown) return
100 this.shown = next
101 this.io.status(next)
102 }
103}
104
105export class Claims {
106 private claimed = new Set<LaneName>()
107 /** Released for the rest of the session: never re-claimed. */
108 private lost = new Set<LaneName>()
109 private failures = new Map<LaneName, number>()
110 /** Released for one in-flight call after a first failure. */
111 private paused = new Set<LaneName>()
112 private writes: Promise<void> = Promise.resolve()
113 /** The session id the variable was last stamped with. */
114 private stampedFor: string | undefined
115 /** Epoch ms the variable's lease runs to (0: no live lease). */
116 private leaseUntil = 0
117 private renewal: { cancel(): void } | undefined
118 private pid: Promise<string> | undefined
119
120 /**
121 * `clock` is epoch ms, read synchronously so `has()` stays synchronous; it
122 * is `Date.now`, which reads what `$.clock.now()` and Python's
123 * `time.time()` read (verified on Claude Code 2.1.292: equal).
124 */
125 constructor(
126 private io: Io,
127 private env: Env,
128 private status: StatusLine,
129 private clock: () => number = () => Date.now(),
130 ) {}
131
132 /** The mod serves `lane`: claimed, and its lease (written to the variable) not long lapsed. */
133 has(lane: LaneName): boolean {
134 return this.claimed.has(lane) && this.clock() < this.leaseUntil + LEASE_LATE_MS
135 }
136
137 list(): LaneName[] {
138 return LANES.filter(l => this.claimed.has(l))
139 }
140
141 /**
142 * A fresh process (nothing in `$.state` yet) must not trust a variable it
143 * did not write: a `claude` started from a Bash call inherits its parent's.
144 * Cleared before anything else runs, so Python serves until we claim.
145 */
146 async forgetInherited(): Promise<void> {
147 if ((await this.io.getLanesVar(this.env)) !== undefined) await this.io.setLanesVar(this.env, undefined)
148 }
149
150 /** Add lanes the mod is now ready to serve. A lane released for the session stays released. */
151 async claim(lanes: readonly LaneName[]): Promise<void> {
152 const added = lanes.filter(l => !this.lost.has(l) && !this.claimed.has(l))
153 for (const l of added) this.claimed.add(l)
154 try {
155 await this.write()
156 } catch (err) {
157 // The variable may not list them: serving them here too would double-fire.
158 for (const l of added) this.claimed.delete(l)
159 throw err
160 }
161 }
162
163 /** Give lanes back to Python for the rest of the session. */
164 async release(lanes: readonly LaneName[] = LANES): Promise<void> {
165 for (const l of lanes) {
166 this.claimed.delete(l)
167 this.lost.add(l)
168 }
169 await this.write()
170 }
171
172 /** A failure while the mod was loading: nothing is (or stays) claimed, and the person is told. */
173 async failLoad(): Promise<void> {
174 await this.release(LANES)
175 this.status.set('claims', FELL_BACK)
176 }
177
178 /**
179 * A lane hook failed. The lane leaves the variable at once (so the Python
180 * hook serves this call if it has not run yet); on the second failure it
181 * stays out for the session.
182 */
183 async fail(lane: LaneName): Promise<void> {
184 // A lane paused by a failure still in flight counts too (parallel calls).
185 if (!this.claimed.has(lane) && !this.paused.has(lane)) return
186 const n = (this.failures.get(lane) ?? 0) + 1
187 this.failures.set(lane, n)
188 this.claimed.delete(lane)
189 if (n >= MAX_FAILURES) {
190 this.lost.add(lane)
191 this.paused.delete(lane)
192 this.status.set('claims', FELL_BACK)
193 } else {
194 this.paused.add(lane)
195 }
196 await this.write()
197 }
198
199 /**
200 * The session id changed in this process (a /clear or a resume: no
201 * session.start fires) — stamp the claim with the new one. Resolves true
202 * when the id differs from the stamp the variable carried, i.e. Python
203 * served the new session until now (its SessionStart included).
204 */
205 async restamp(): Promise<boolean> {
206 if (this.stampedFor === undefined) return false
207 const sid = await this.io.sessionId()
208 if (sid === this.stampedFor) return false
209 if (this.claimed.size) await this.write()
210 else this.stampedFor = sid
211 return true
212 }
213
214 /**
215 * The renewal tick: push the lease out for the lanes still claimed. A
216 * released, lost or paused lane is not in `claimed`, so a renewal never
217 * brings one back; nothing claimed writes nothing.
218 */
219 async renew(): Promise<void> {
220 if (!this.claimed.size) return
221 await this.write()
222 }
223
224 /** After the call a first failure handed to Python: take the lane back. */
225 async recover(lane: LaneName): Promise<void> {
226 if (!this.paused.delete(lane) || this.lost.has(lane)) return
227 this.claimed.add(lane)
228 await this.write()
229 }
230
231 /**
232 * The variable and the mirror, written in order (one write at a time). The
233 * value is `<session_id>:<expires_epoch_s>:<pid>:<lanes>`, the id and the
234 * clock read at write time; no lanes unsets it. A claim that cannot be
235 * stamped is not made: with no session id every lane goes back to Python
236 * (the variable unset) and the write rejects.
237 */
238 private write(): Promise<void> {
239 this.writes = this.writes
240 .catch(() => undefined)
241 .then(() => this.writeOnce().catch(err => this.writeFailed(err)))
242 return this.writes
243 }
244
245 /**
246 * A write rejected: the variable may still hold its previous value, listing
247 * a lane this module has dropped (a `fail`, a `release`) and so leaving that
248 * lane evaluated by nobody. Serve nothing (no live lease: `has()` is false
249 * for every lane) and try to unset the variable, so Python serves every
250 * lane; if the unset fails too, the variable's own lease lapses within
251 * LEASE_S. The next renewal (or claim) writes the claim afresh. Rejects with
252 * the write's error.
253 */
254 private async writeFailed(err: unknown): Promise<never> {
255 this.leaseUntil = 0
256 const unset = await this.io.setLanesVar(this.env, undefined).then(() => true, () => false)
257 if (unset) await this.io.setState('lanes', []).catch(() => undefined)
258 throw err
259 }
260
261 private async writeOnce(): Promise<void> {
262 let lanes = this.list()
263 let value: string | undefined
264 if (lanes.length) {
265 const sid = await this.io.sessionId().catch(() => undefined)
266 if (!sid) {
267 this.claimed.clear()
268 this.leaseUntil = 0
269 await this.io.setLanesVar(this.env, undefined)
270 lanes = []
271 await this.io.setState('lanes', lanes).catch(() => undefined)
272 throw new Error('no session id to stamp the claim with')
273 }
274 const pid = await this.ownPid()
275 // Re-read after the awaits: a release that landed meanwhile wins.
276 lanes = this.list()
277 if (lanes.length) {
278 const expires = Math.floor(this.clock() / 1000) + LEASE_S
279 this.stampedFor = sid
280 value = stampOf(sid, expires, pid, lanes)
281 await this.io.setLanesVar(this.env, value)
282 this.leaseUntil = expires * 1000
283 this.renewing()
284 }
285 }
286 if (value === undefined) {
287 this.leaseUntil = 0
288 await this.io.setLanesVar(this.env, undefined)
289 }
290 // The mirror is for display; a failed mirror must not undo a claim.
291 await this.io.setState('lanes', lanes).catch(() => undefined)
292 }
293
294 /** Start the renewal timer once. It dies with the module, which is what lets a dead module's lease lapse. */
295 private renewing(): void {
296 if (this.renewal) return
297 this.renewal = this.io.every(RENEW_MS, () => {
298 void this.renew().catch(() => undefined)
299 })
300 }
301
302 /**
303 * This Claude Code process's pid, which its hook commands see as
304 * `CLAUDE_PID`: the parent of a shell `$.process.run` starts (verified on
305 * 2.1.292; `$.env.get('CLAUDE_PID')` would read an ANCESTOR's, inherited).
306 * '' when it cannot be had (no /bin/sh): Python then matches on the session.
307 */
308 private ownPid(): Promise<string> {
309 this.pid ??= this.io
310 .run(['/bin/sh', '-c', 'echo $PPID'], { timeoutMs: 5_000 })
311 .then(r => {
312 const out = r.stdout.trim()
313 return r.exitCode === 0 && /^\d{1,10}$/.test(out) ? out : ''
314 })
315 .catch(() => '')
316 return this.pid
317 }
318}
319
320/** The variable's value (mod_lanes.py `lanes_for` reads it). */
321export function stampOf(sid: string, expiresEpochS: number, pid: string, lanes: readonly LaneName[]): string {
322 return `${sid}:${expiresEpochS}:${pid}:${lanes.join(',')}`
323}
324mod/ctx.ts 154 lines1// What every part of the mod shares, resolved once per session at
2// `session.start` by mod/register.ts.
3//
4// Two things live here:
5// * `Ctx`: the environment (staging | prod), the plugin root and the REST
6// credential, cached in module memory.
7// * `Io`: the narrow port every part of the shell (claims, book, act,
8// lanes) does its I/O through. register.ts builds it from `$` (`ioOf`,
9// which must live there: the engine follows `$` only into functions of
10// the file that holds it); the tests build a fake one. Nothing but
11// register.ts touches `$`, so each part is testable without the engine
12// beneath it.
13
14import type { FireNote, LaneName } from '../types'
15
16export type Env = 'staging' | 'prod'
17
18export type Api = {
19 base: string
20 bearer: string
21 /**
22 * The MemHub Studio web app paired with `base` (api-info's `studio`:
23 * plugin_onboarding._ORIGINS), or absent for an API with none. The mod
24 * links a rule's Studio page from it and keeps no host table of its own.
25 */
26 studio?: string
27}
28
29export type Ctx = {
30 /** 'staging' for memhub-staging, 'prod' for memhub (promote_export.py changes the name). */
31 env: Env
32 /** The plugin's install directory (`$.plugin.root`, without a trailing `/.claude-plugin`). */
33 root: string
34 /**
35 * The REST base, bearer and Studio origin, from `python3 scripts/rulebook_mod_cli.py
36 * api-info --env <env>`; cached in module memory only (never $.state /
37 * $.store: it is a secret). undefined when signed out.
38 */
39 api(): Promise<Api | undefined>
40 /** Drop the cached credential (after a 401) so the next api() re-resolves. */
41 forgetApi(): void
42}
43
44/** The keys of this plugin's `$.state` contract (types/index.d.ts) the shell writes. */
45export type StateShape = {
46 fires: FireNote[]
47 lanes: LaneName[]
48 health: string
49}
50
51export type RunResult = { exitCode: number; stdout: string; stderr: string }
52export type HttpReply = { status: number; ok: boolean; headers: Record<string, string>; text: string }
53
54/** Every effect the shell has, as one port. */
55export interface Io {
56 pluginName: string
57 root: string
58 now(): Promise<number>
59 sessionId(): Promise<string>
60 cwd(): Promise<string>
61 run(argv: readonly string[], init?: { stdin?: string; timeoutMs?: number; cwd?: string }): Promise<RunResult>
62 fetch(url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }): Promise<HttpReply>
63 /** Rejects when the file is missing or unreadable. */
64 readFile(path: string): Promise<string>
65 writeFile(path: string, text: string): Promise<void>
66 /** `MEMHUB_MOD_LANES_<ENV>`: written only by claims.ts. */
67 getLanesVar(env: Env): Promise<string | undefined>
68 setLanesVar(env: Env, value: string | undefined): Promise<void>
69 getState<K extends keyof StateShape>(key: K): Promise<{ value: StateShape[K] | undefined; version: number }>
70 setState<K extends keyof StateShape>(key: K, value: StateShape[K]): Promise<void>
71 status(text: string | undefined): void
72 /** A dim transcript line for the person (the model never reads it). */
73 log(text: string): void
74 /** A debug-log line only (`$.ui.log(text, { to: 'debug' })`): never on screen. */
75 debug(text: string): void
76 /** `$.session.append`: `user` = an isMeta row the model reads; `system` = a notice it never reads. */
77 append(type: 'user' | 'system', text: string): Promise<void>
78 every(ms: number, fn: () => void): { cancel(): void }
79 /** `$.clock.sleep`: the mod has no setTimeout; what a request races to bound itself. */
80 sleep(ms: number): Promise<void>
81}
82
83/** memhub-staging → staging, memhub → prod; any other name is not ours to guess. */
84export function envOf(pluginName: string): Env | undefined {
85 if (pluginName === 'memhub-staging') return 'staging'
86 if (pluginName === 'memhub') return 'prod'
87 return undefined
88}
89
90/** The last non-empty stdout line as JSON, or undefined. Scripts may print progress first. */
91export function lastJson(stdout: string): unknown {
92 const lines = stdout.split('\n').map(l => l.trim()).filter(Boolean)
93 const last = lines.at(-1)
94 if (last === undefined) return undefined
95 try {
96 return JSON.parse(last)
97 } catch {
98 return undefined
99 }
100}
101
102/**
103 * The scope label of a REST rule row's rulebook ("Everyone in Acme", "Just
104 * you"): who the rule applies to. '' for a backend that sends none.
105 */
106export function audienceOf(row: Record<string, unknown>): string {
107 const book = row.rulebook
108 const label = book && typeof book === 'object' ? (book as { label?: unknown }).label : undefined
109 // one line, bounded: it is drawn on the band
110 // eslint-disable-next-line no-control-regex
111 return typeof label === 'string' ? label.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 120) : ''
112}
113
114export const MOD_CLI = 'scripts/rulebook_mod_cli.py'
115
116export function makeCtx(io: Io, env: Env): Ctx {
117 // The resolved credential, kept for the session. A signed-out or failed
118 // answer is not kept: a person who runs /memhub:login mid-session is picked
119 // up on the next refresh tick (only the tick and the judge ask).
120 let cached: Promise<Api | undefined> | undefined
121 const resolve = async (): Promise<Api | undefined> => {
122 const r = await io.run(['python3', `${io.root}/${MOD_CLI}`, 'api-info', '--env', env], { timeoutMs: 10_000 })
123 if (r.exitCode !== 0) throw new Error(`api-info exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`)
124 const got = lastJson(r.stdout) as Partial<Api> | undefined
125 if (got && typeof got.base === 'string' && got.base && typeof got.bearer === 'string' && got.bearer) {
126 const studio = typeof got.studio === 'string' ? got.studio.replace(/\/+$/, '') : ''
127 return { base: got.base.replace(/\/+$/, ''), bearer: got.bearer, ...(studio ? { studio } : {}) }
128 }
129 return undefined
130 }
131 return {
132 env,
133 root: io.root,
134 async api() {
135 if (!cached) cached = resolve()
136 const mine = cached
137 try {
138 const api = await mine
139 if (!api && cached === mine) cached = undefined
140 return api
141 } catch {
142 if (cached === mine) cached = undefined
143 return undefined
144 }
145 },
146 forgetApi() {
147 cached = undefined
148 },
149 }
150}
151
152/** `$.plugin.root` names the folder holding plugin.json; the scripts sit beside `.claude-plugin/`. */
153export const rootOf = (pluginRoot: string) => pluginRoot.replace(/\/\.claude-plugin\/?$/, '')
154mod/engine/index.ts 149 lines1// The Rulebook engine the mod's lanes call: a TypeScript port of the parts of
2// scripts/rulebook_hook.py that decide what fires. Python stays the engine on
3// Codex, Cursor and wherever the mod does not load; the golden vectors
4// (scripts/rule_vectors.py, generated into ./tests/vectors/ by
5// scripts/test-mod.sh for each test run) hold the port to its answers.
6//
7// The engine decides; the mod acts (mod/act.ts): it denies, adds context,
8// writes the disclosure row, asks, and feeds the companion. Nothing in
9// ./engine touches `$` — I/O comes in through EngineIO, so every layer is a
10// pure function a vector can replay.
11
12/** A rule in the flat shape `to_hook_rule()` returns (rules.ts). */
13export type HookRule = {
14 id: string
15 on: string
16 mode?: string
17 text?: string
18 why?: string
19 _label?: string | null
20 [k: string]: unknown
21}
22
23/** One tool call as the lanes see it. */
24export type CallEvent = {
25 phase: 'pre' | 'post'
26 tool: string
27 input: Readonly<Record<string, unknown>>
28 sessionId: string
29 cwd: string
30 /** Set when a subagent made the call (replaces the /subagents/ path test). */
31 agentId?: string
32 /** The call's tool_use id: pairs a Bash call's pre with its post (parallel calls). */
33 toolUseId?: string
34 /** Post phase only. */
35 result?: { text?: string; isError?: boolean }
36}
37
38/** One rule that fired on this event. */
39export type Fire = {
40 ruleId: string
41 label: string
42 /** `disclosure_line(rule, blocked)`, byte-identical to Python. */
43 line: string
44 mode: 'advise' | 'gate'
45 text: string
46}
47
48export type Verdict = {
49 /** Set when a gate refuses the call: the reason the model reads. */
50 deny?: string
51 /** Text the model reads after the result (advisories, post-lane notes). */
52 context: string[]
53 fires: Fire[]
54 /** Fire/event rows for the Python ledger (`rulebook_mod_cli.py log`). */
55 ledger: Record<string, unknown>[]
56}
57
58/** One file-system entry as `$.fs.stat` answers it. */
59export type FileStat = { kind: 'file' | 'dir' | 'other'; size: number; mtimeMs: number; realPath?: string }
60
61/** One message in Messages API form (`$.session.messages({ as: "api" })`). */
62export type TurnMessage = { role: 'user' | 'assistant'; content: readonly Record<string, unknown>[] | string }
63
64/** The environment variables the Python hook reads, as the mod's process has them. */
65export type EngineEnv = {
66 /** MEMHUB_RULEBOOK_RECALL: "0" turns the anchor lane off. */
67 recall?: string
68 /** MEMHUB_RULEBOOK_JUDGE: "0" turns the judge off. */
69 judge?: string
70 /** MEMHUB_RULEBOOK_TIMEOUT_S: the network timeout override. */
71 timeoutS?: string
72 /** MEMHUB_RULEBOOK_BASE_BRANCH: the diff probes' explicit base. */
73 baseBranch?: string
74 /** MEMHUB_BRIEF_TOKEN_BUDGET: the session-start budget the posture rules take a third of. */
75 briefBudget?: string
76}
77
78/** `rulebook_mod_cli.py paths --cwd <dir>`: what the Python hook resolves for a call made there. */
79export type PathsInfo = { repo: string; root: string | null; base: string }
80
81/**
82 * One write to the state the Python hooks share (`scripts/rulebook_mod_state.py`,
83 * which takes their lock): an ordering feed that writes, an arming, a discharge.
84 */
85export type StateOp =
86 | { op: 'feed'; root: string; rule: HookRule; hook_phase: string; tool: string; cmd: string; file_path: string; ok: boolean | null; armed: string | null }
87 | { op: 'arm'; session: string; event: string; prompt: string; rules: HookRule[]; repo: string; gitdir: string }
88 | { op: 'drop'; session: string; rule_ids: string[] }
89 | { op: 'specs'; root: string; spec_dir: string; paths: string[] }
90
91/** The engine's only way out of pure code. */
92export interface EngineIO {
93 /** `git <argv>` (argv WITHOUT the leading `git`); rejects on a timeout, as Python's `subprocess.run` raises. */
94 git(argv: readonly string[], cwd: string, timeoutMs?: number): Promise<{ code: number; stdout: string }>
95 /** A file's text; undefined when missing, unreadable or past the 4 MiB `$.fs` cap. */
96 readText(path: string): Promise<string | undefined>
97 /** Best effort; never throws. */
98 writeText(path: string, text: string): Promise<void>
99 /** `os.stat` (`resolve`: also the real path); undefined when missing. */
100 stat(path: string, resolve?: boolean): Promise<FileStat | undefined>
101 /** Newline count of a file too big for `readText` (`read_facts`' loop); undefined on failure. */
102 countLines(path: string): Promise<number | undefined>
103 /** Milliseconds since the epoch. */
104 now(): number
105 /** Resolves after `ms` (the judge's own timeout race). */
106 sleep(ms: number): Promise<void>
107 /** The local UTC offset at `ms`, in minutes east (Python's `astimezone()`). */
108 tzOffsetMinutes(ms: number): number
109 /** $HOME, for `~` and the identity redaction. */
110 home: string
111 env(): Promise<EngineEnv>
112 /** `rulebook_mod_cli.py paths --cwd <dir>`; undefined when it cannot answer. */
113 paths(dir: string): Promise<PathsInfo | undefined>
114 /** One write through `rulebook_mod_state.py`; undefined when the helper failed as a whole. */
115 state(ops: readonly StateOp[], cwd: string): Promise<Record<string, unknown>[] | undefined>
116 /**
117 * POST /judge with `body`: the HTTP status and the unwrapped `data`; undefined
118 * when the call could not be made or completed (no credential, network error,
119 * timeout — the engine adds its own race too), which Python treats as
120 * "nothing judged". A 404 is answered `{ status: 404 }`.
121 */
122 judge(body: Record<string, unknown>): Promise<{ status: number; data?: unknown } | undefined>
123 /**
124 * The conversation the judge reads (`rule_judge_turn.read_turn`'s input),
125 * and an id that is stable for the length of one human turn; undefined when
126 * there is none to read.
127 */
128 turn(): Promise<{ id: string; messages: readonly TurnMessage[] } | undefined>
129}
130
131export interface Engine {
132 /** Replace the book (already shaped by `toHookRule`). */
133 setBook(rules: readonly HookRule[], meta: { repo: string; fetchedAt: number }): void
134 pre(e: CallEvent): Promise<Verdict>
135 post(e: CallEvent): Promise<Verdict>
136 prompt(text: string, sessionId: string, cwd: string): Promise<Verdict>
137 /**
138 * The session lane. `servedAlready`: the posture preamble is already in the
139 * conversation (Python's SessionStart served it at startup, or the module
140 * before a hot reload) — the engine then only arms, and records nothing.
141 * `ledger`: the posture fire rows, for `rulebook_mod_cli.py log`.
142 */
143 session(
144 sessionId: string,
145 cwd: string,
146 opts?: { servedAlready?: boolean },
147 ): Promise<{ context: string[]; ledger?: Record<string, unknown>[] }>
148}
149mod/engine/engine.ts 1324 lines1// The Rulebook engine (mods spec §4.1–4.8): a port of rulebook_hook.py
2// `main()`'s lane orchestration — the pre, post, prompt and session lanes —
3// over the layers beside it (rules.ts, shell.ts, ordering.ts, pyre.ts) and
4// EngineIO. scripts/rule_vector_cases/lanes.py runs the REAL Python lanes on
5// recorded hook sequences; ./tests/lanes.test.ts replays them through this
6// engine and fails on any difference. Read main() beside this file: the
7// order of every step below is its order.
8//
9// What the engine keeps, and where:
10// * the session's dedup state (`fired`, `counts`, `raw`, `spec_pending`,
11// the judge's per-turn cache, the Bash pre-call stamps) in memory, one
12// object per session id, seeded ONCE from the Python's
13// `state/<session>.json` (lockless: Python replaces it atomically) so
14// whatever Python served before the claim stays deduped;
15// * the session's ARMINGS (`armed`, `armed_version`, `armed_once`) in that
16// same file, shared with the Python lanes: re-read at each tool call that
17// could need them, written only through scripts/rulebook_mod_state.py,
18// which takes the Python's lock and merges by delta;
19// * the per-checkout ordering obligations in `state/wt-<worktree_key>.json`:
20// a gate reads it lockless; an arm (an edit) and a discharge (a green
21// receipt) go through the same script, under the same lock.
22//
23// Port notes (each a known, accepted difference from the Python):
24// * `$.session.messages({ as: "api" })` stands in for the transcript file
25// (given.user, the judge's turn): `isMeta` and compaction-summary rows are
26// not visible in that form, and `source_message_id` is always null;
27// * the post lane's result text: for a Bash call it is built from the
28// tool's own record (`stdout`, `stderr`) exactly as `result_text` builds
29// it from `tool_response` (lanes.ts `postResultText`), so a large output
30// Claude Code persisted to a file is matched on the same ~30,000-character
31// `stdout` Python gets, not the ~2 KB preview the model reads. Still
32// different: every non-Bash tool, a Bash error result, and a Bash output
33// with no stdout or stderr read `text` (Python matches the raw
34// `tool_response` dict, or its `json.dumps`, there);
35// * no `show_upgrade` (a book suspended by an upgrade notice is never loaded:
36// book.ts), no `maybe_refresh` / `refresh_if_stale` (book.ts refreshes on a
37// timer), no `.sources` audit file, no breadcrumbs, no legacy conversions;
38// * a call whose checkout belongs to ANOTHER repo than the loaded book
39// throws ForeignRepoError: the lane hands that call to Python (which loads
40// that repo's book) rather than judge it against the wrong rules;
41// * a rule pyre cannot express (`_unportable`) is never skipped: while one
42// is in the book every tool and prompt lane throws UnportableRuleError,
43// the shell hands the call to Python and, on the second, releases the
44// lane for the session (claims.ts) — Python then serves it whole.
45// * a GATE-mode ordering receipt discharges on a Bash result that is not
46// an error. Python's `bash_ok(strict=True)` needs an explicit `exit_code`,
47// and neither Claude Code's PostToolUse payload nor the mod's tool result
48// carries one, so on Claude Code the Python lane never discharges a gate
49// (harness/cases/README.md, "A gate-mode receipt never discharges under
50// Claude Code"). The mod knows `isError` for the call it ran, and Claude
51// Code sets it for a Bash call that exited non-zero, so the mod reads a
52// non-error result as exit 0: a deliberate divergence. The lane vectors
53// pin it: `ordering-gate-receipt-without-exit-code` stays blocked in the
54// Python and is allowed here (tests/lanes.test.ts, MOD_DIVERGES);
55// * any other exception is Python's `except BaseException: rc = 0`: the call
56// gets nothing (no gate, no advice), and this call's state changes are
57// dropped, as an unsaved Python state file drops them.
58
59import type { CallEvent, Engine, EngineEnv, EngineIO, Fire, HookRule, TurnMessage, Verdict } from './index'
60import { judgeFires, judgeTimeoutMs, unfire, type JudgeState, type Marks } from './judge'
61import { OrderingEngine, armsOn, sessionScoped } from './ordering'
62import {
63 BRAND, dismissalLines, findEditOverride, findOverride, labelOf, namedRules, resolveDismissals,
64 splitNamedOverride, stripOverride,
65} from './override'
66import { givenOk, Probes, pyCompare, type SpecHit } from './probes'
67import { isoMicros, pyExec, pyMatch } from './py'
68import { pySearch } from './pyre'
69import { Repos, sessionFileName, worktreeKey, type RepoInfo } from './repo'
70import {
71 bookRank, cpLen, cpSlice, disclosureLine, EDIT_TOOLS, editAddedText, evaluate, isDict, oneLine, pyStr, pyStrip, READ_TOOLS, truthy,
72} from './rules'
73import {
74 anchorHits, bashReads, join, pathInScope, redactSecrets, relpath, shellOnly, stripComments,
75} from './shell'
76
77type Dict = Record<string, unknown>
78
79export const MAX_ADVISE = 2
80export const MAX_POSTURE = 15
81const BASH_EDIT_MAX_FILES = 40
82const BASH_EDIT_MAX_BYTES = 512 * 1024
83const BASH_EDIT_MAX_STATUS = 4000
84const BASH_EDIT_MARKS_KEPT = 8
85const FS_READ_MAX = 4 * 1024 * 1024
86const MAX_BOOKS_NAMED = 6
87const ROSTER_MAX_CHARS = 1000
88const SPEC_LIST_MAX = 3
89const SPEC_OWNED_MAX = 5
90
91const TREE_REWRITE_RX = String.raw`(?:^|[;&|(]\s*)git\s+(?:-C\s+\S+\s+)?(?:checkout|switch|stash|merge|rebase|pull|reset` +
92 String.raw`|cherry-pick|revert|apply|am|restore|worktree)\b`
93const HARNESS_PROMPT_RX = String.raw`\s*(?:<(?:command-name|command-message|command-args|local-command-stdout` +
94 String.raw`|local-command-stderr|local-command-caveat|system-reminder|task-notification)>` +
95 String.raw`|This session is being continued|Caveat: The messages below|Base directory for this skill:` +
96 String.raw`|Another Claude session sent a message:)`
97const AGENT_MESSAGE_RX = String.raw`\s*Another Claude session sent a message:`
98const BASH_RED_RX = String.raw`(^|\n)(FAILED|ERROR)\b|\b\d+ (failed|errors?)\b|\nTraceback \(most recent call last\)` +
99 String.raw`|(^|\n)npm ERR!|(^|\n)error(\[E\d+\])?:`
100
101export const ADVISE_FEEDBACK_HINT =
102 "_If you go on without following one of these, say why on your next shell command — " +
103 "`RULEBOOK_OVERRIDE='[<label>] <why>' <command>` — so the reason is recorded against " +
104 'that rule instead of silence._'
105
106export const SESSION_PREAMBLE =
107 "These are your team's engineering rules — standing instructions from your teammates, " +
108 "carrying the same weight as this repo's CLAUDE.md. Follow them as you would CLAUDE.md: " +
109 'they are how this team works, not suggestions to weigh. When one fires, you MUST disclose ' +
110 'it to the user on its own line, exactly `📏 Rule fired: <the rule, in 20 words or fewer>`, ' +
111 'before anything else in that reply.'
112
113/** A call in a checkout of another repo than the book's: Python serves it. */
114export class ForeignRepoError extends Error {
115 constructor(repo: string, book: string) {
116 super(`call is in ${repo}, the loaded book is ${book}`)
117 this.name = 'ForeignRepoError'
118 }
119}
120
121/** The book holds a rule pyre cannot run: the lane must be Python's. */
122export class UnportableRuleError extends Error {
123 constructor(ids: readonly string[]) {
124 super(`rules not portable to the mod: ${ids.slice(0, 3).join(', ')}`)
125 this.name = 'UnportableRuleError'
126 }
127}
128
129// ── small Python-isms ───────────────────────────────────────────────────────
130
131const has = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k)
132/** `r.get("status", "active") == "active"` */
133const isActive = (r: HookRule) => (has(r, 'status') ? r.status : 'active') === 'active'
134/** `d.get(k, dflt)` */
135const getOr = (d: Dict, k: string, dflt: unknown): unknown => (has(d, k) ? d[k] ?? null : dflt)
136/** `str(v)` of a payload value read with `.get(k, "")`. */
137const strOf = (d: Dict, k: string): string => pyStr(getOr(d, k, ''))
138const casefold = (s: string) => s.toLowerCase()
139
140/** `int(x)` as read_facts and the counter scope accept it, else null (Python's except). */
141function pyIntLoose(v: unknown): number | null {
142 if (typeof v === 'boolean') return Number(v)
143 if (typeof v === 'number' && Number.isFinite(v)) return Math.trunc(v)
144 if (typeof v === 'string' && /^\s*[+-]?\d+(_\d+)*\s*$/.test(v)) return Number(v.replace(/_/g, ''))
145 return null
146}
147
148/** `json.dumps(v)` for the string lists the state keys are made of. */
149function pyDumps(v: unknown): string {
150 if (Array.isArray(v)) return '[' + v.map(pyDumps).join(', ') + ']'
151 if (typeof v === 'string') {
152 return JSON.stringify(v).replace(/[\u0080-\uffff]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'))
153 }
154 return JSON.stringify(v ?? null)
155}
156
157/** `scope_ok(rule, repo, gitdir)` */
158export function scopeOk(rule: HookRule, repo: string, gitdir: string): boolean {
159 const scope = has(rule, 'repo_scope') ? rule.repo_scope : 'any'
160 const repos = rule._scope_repos
161 if (truthy(repos)) {
162 const parts = gitdir ? gitdir.split('/') : []
163 const i = parts.indexOf('.git')
164 const main = i > 0 ? parts[i - 1]! : ''
165 const here = new Set([casefold(repo), casefold(main)].filter(Boolean))
166 return (repos as unknown[]).some((s) => here.has(casefold(pyStr(s))))
167 }
168 if (scope === 'any') return true
169 if (typeof scope !== 'string') throw new TypeError("'in <string>' requires string as left operand")
170 return repo.includes(scope) || (!!gitdir && gitdir.includes(`/${scope}/`))
171}
172
173/** A rule as the shell layer's scope/anchor helpers read it. */
174const sc = (r: HookRule) => r as unknown as { _scope_paths?: unknown; _scope_exclude_paths?: unknown; anchors?: unknown }
175
176/** `harness_prompt(text)` */
177export const harnessPrompt = (text: string): boolean => pyMatch(HARNESS_PROMPT_RX, text || '') !== null
178
179/** `agent_message(text)`: another agent's message — it fires no `prompt` rule. */
180export const agentMessage = (text: string): boolean => pyMatch(AGENT_MESSAGE_RX, text || '') !== null
181
182/** `_why(r)` */
183const why = (r: HookRule) => (truthy(r.why) ? ` _(why: ${pyStr(r.why)})_` : '')
184
185/** `_spec_untouched_text(text, hits)` */
186function specUntouchedText(text: string, hits: readonly SpecHit[]): [string, string] {
187 const shown = hits.slice(0, SPEC_LIST_MAX)
188 const more = hits.length - shown.length
189 let names = shown.map(([p]) => oneLine(p)).join(', ')
190 if (more > 0) names += ` (+${more} more)`
191 if (!names) return [text, '']
192 const owned = shown.map(([p, paths]) => {
193 let head = paths.slice(0, SPEC_OWNED_MAX).map(oneLine).join(', ')
194 if (paths.length > SPEC_OWNED_MAX) head += ` (+${paths.length - SPEC_OWNED_MAX} more)`
195 return `${oneLine(p)} owns ${head}`
196 })
197 const detail = owned.length ? ` _(changed owned paths: ${owned.join('; ')})_` : ''
198 return [`${text.replace(/[\s]+$/u, '')} Specs not updated: ${names}.`, detail]
199}
200
201/**
202 * `bash_ok(resp, strict)` over what the mod sees of a Bash result, which never
203 * carries an exit code. Python's strict (gate-mode receipt) answer without one
204 * is always False; here `isError` stands in for it: Claude Code marks a Bash
205 * call that exited non-zero as an error, so a strict receipt is a Bash result
206 * that is not one (the port note "a gate-mode receipt discharges" above).
207 */
208function bashOk(resp: { text: string; isError?: boolean } | null, strict: boolean): boolean {
209 if (resp === null) return false
210 if (resp.isError) return false
211 if (strict) return true
212 return !pySearch(BASH_RED_RX, resp.text)
213}
214
215const cmpTuple = (a: readonly (number | string)[], b: readonly (number | string)[]): number => {
216 for (let i = 0; i < Math.min(a.length, b.length); i++) {
217 const x = a[i]!
218 const y = b[i]!
219 const d = typeof x === 'number' && typeof y === 'number' ? x - y : pyCompare(String(x), String(y))
220 if (d) return d
221 }
222 return a.length - b.length
223}
224
225// ── session state ───────────────────────────────────────────────────────────
226
227type SessionState = JudgeState & {
228 fired: string[]
229 counts: Record<string, number>
230 raw: Record<string, number>
231 armed: Record<string, unknown>
232 armed_once: string[]
233 armed_version: Record<string, unknown>
234 spec_pending: Record<string, unknown>
235 bash_t0: Map<string, number>
236}
237
238function freshState(): SessionState {
239 return { fired: [], counts: {}, raw: {}, armed: {}, armed_once: [], armed_version: {}, spec_pending: {}, bash_t0: new Map() }
240}
241
242/** `load_state`'s defaulting, over a parsed file. */
243function stateFrom(doc: unknown): SessionState {
244 const st = freshState()
245 if (!isDict(doc)) return st
246 const list = (v: unknown) => (Array.isArray(v) ? v.map((x) => x as string) : [])
247 const dict = (v: unknown) => (isDict(v) ? { ...v } : {})
248 st.fired = list(doc.fired)
249 st.counts = dict(doc.counts) as Record<string, number>
250 st.raw = dict(doc.raw) as Record<string, number>
251 st.armed = dict(doc.armed)
252 st.armed_once = list(doc.armed_once)
253 st.armed_version = dict(doc.armed_version)
254 st.spec_pending = dict(doc.spec_pending)
255 if (isDict(doc.judge)) st.judge = doc.judge as SessionState['judge']
256 if (isDict(doc.bash_t0)) {
257 for (const [k, v] of Object.entries(doc.bash_t0)) if (typeof v === 'number') st.bash_t0.set(k, v)
258 }
259 return st
260}
261
262const cloneState = (st: SessionState): SessionState => {
263 const c = stateFrom(JSON.parse(JSON.stringify({ ...st, bash_t0: Object.fromEntries(st.bash_t0) })))
264 if (!st.judge) delete c.judge
265 return c
266}
267
268/** An event of one call (main()'s `events` entries). */
269type Ev = {
270 tool: string
271 phase: string
272 order_phase: string
273 cmd: string
274 fp: string
275 body: string
276 /** What the call ADDED, for edit rules' content_rx; null = read `body`. */
277 added?: string | null
278 rtext: string
279 resp: { text: string; isError: boolean } | null
280 via: 'bash' | 'bash-read' | null
281 read: Dict | null
282}
283
284type Ctx = {
285 session: string
286 agent_id: string | null
287 repo: string
288 branch: string
289 tool: string
290 worktree: string | null
291}
292
293// ── the engine ──────────────────────────────────────────────────────────────
294
295export function createEngine(io: EngineIO): Engine {
296 return new RulebookEngine(io)
297}
298
299class RulebookEngine implements Engine {
300 private rules: HookRule[] = []
301 private repo = ''
302 private loaded = false
303 private unportable: string[] = []
304 private sessions = new Map<string, SessionState>()
305 private repos: Repos
306
307 constructor(private io: EngineIO) {
308 this.repos = new Repos(io)
309 }
310
311 setBook(rules: readonly HookRule[], meta: { repo: string; fetchedAt: number }): void {
312 this.rules = rules.map((r) => ({ ...r }))
313 this.repo = meta.repo
314 this.loaded = true
315 this.unportable = rules.filter((r) => truthy(r._unportable) && r.on !== 'session').map((r) => r.id)
316 }
317
318 // ── shared plumbing ──────────────────────────────────────────────────────
319
320 private assertPortable(): void {
321 if (this.unportable.length) throw new UnportableRuleError(this.unportable)
322 }
323
324 /** A per-call copy: Python loads the book fresh per process, and the fire pass writes on rules. */
325 private book(): HookRule[] {
326 return this.rules.map((r) => ({ ...r }))
327 }
328
329 private async where(cwd: string, tool: string, inp: Dict): Promise<RepoInfo | undefined> {
330 const info = await this.repos.ofCall(cwd, tool, inp)
331 if (!info.repo) return undefined
332 if (info.repo !== this.repo) throw new ForeignRepoError(info.repo, this.repo)
333 return info
334 }
335
336 private async baseOf(root: string): Promise<string> {
337 const p = await this.io.paths(root).catch(() => undefined)
338 if (!p?.base) throw new Error('no rulebook base')
339 return p.base
340 }
341
342 private nowUs(): number {
343 return Math.round(this.io.now() * 1000)
344 }
345
346 private iso(us: number): string {
347 return isoMicros(us, this.io.tzOffsetMinutes(Math.floor(us / 1000)))
348 }
349
350 /** The session's state: memory, seeded once from Python's file. */
351 private async state(session: string, base: string): Promise<SessionState> {
352 let st = this.sessions.get(session)
353 if (!st) {
354 const text = await this.io.readText(join(base, 'state', sessionFileName(session)))
355 let doc: unknown
356 try {
357 doc = text === undefined ? undefined : JSON.parse(text)
358 } catch {
359 doc = undefined
360 }
361 st = stateFrom(doc)
362 this.sessions.set(session, st)
363 }
364 return st
365 }
366
367 /** The armings, re-read from the shared file (only when a rule could read them). */
368 private async refreshArmings(st: SessionState, session: string, base: string, rules: readonly HookRule[]) {
369 if (!rules.some((r) => r.on === 'ordering' && sessionScoped(r))) return
370 const text = await this.io.readText(join(base, 'state', sessionFileName(session)))
371 let doc: unknown
372 try {
373 doc = text === undefined ? undefined : JSON.parse(text)
374 } catch {
375 doc = undefined
376 }
377 const f = stateFrom(doc)
378 st.armed = f.armed
379 st.armed_version = f.armed_version
380 st.armed_once = f.armed_once
381 }
382
383 private turns(): () => Promise<readonly TurnMessage[] | undefined> {
384 let got: Promise<readonly TurnMessage[] | undefined> | undefined
385 return () => (got ??= this.io.turn().then((t) => t?.messages, () => undefined))
386 }
387
388 /** `log_fires` row for rulebook_mod_cli.py log. */
389 private fireRow(
390 ctx: Ctx, r: HookRule, phase: string, mode: string, excerpt: string,
391 o: { raw?: Record<string, unknown>; dedup?: Record<string, string>; override?: Record<string, string>; at?: string;
392 judge?: Map<string, { verdict: string; p_fit: number | null }> },
393 ): Record<string, unknown> {
394 const v = o.judge?.get(r.id)
395 return {
396 rule_id: r.id,
397 rulebook_id: r._rulebook_id ?? null,
398 rule_version: r._version ?? null,
399 agent_id: ctx.agent_id,
400 worktree: ctx.worktree,
401 source_message_id: null,
402 repo: ctx.repo,
403 branch: ctx.branch,
404 tool: ctx.tool,
405 hook_phase: phase,
406 mode,
407 dedup_key: o.dedup?.[r.id] ?? null,
408 raw_matches_before_fire: o.raw && has(o.raw, r.id) ? o.raw[r.id] ?? null : null,
409 fired_at: o.at ?? this.iso(this.nowUs()),
410 override_reason: o.override?.[r.id] ?? null,
411 excerpt: cpSlice(excerpt, 0, 160),
412 ...(v ? { judge_score: v.p_fit, judge_verdict: v.verdict } : {}),
413 }
414 }
415
416 /** `log_event` row. `worktree` undefined = the call's checkout; null = none (a session-scoped receipt). */
417 private eventRow(ctx: Ctx, kind: string, o: { rule_id?: string; reason?: string | null; worktree?: string | null; at?: string }) {
418 return {
419 kind,
420 rule_id: o.rule_id ?? null,
421 agent_id: ctx.agent_id,
422 worktree: o.worktree !== undefined ? o.worktree : ctx.worktree,
423 repo: ctx.repo,
424 branch: ctx.branch,
425 reason: o.reason ?? null,
426 at: o.at ?? this.iso(this.nowUs()),
427 }
428 }
429
430 // ── ordering ─────────────────────────────────────────────────────────────
431
432 /**
433 * OrderingEngine.feed: decided on a lockless read of the worktree file; an
434 * outcome that WRITES (an edit arming, a green receipt) is decided again by
435 * the Python under its lock (rulebook_mod_state.py), whose answer stands.
436 */
437 private async feed(
438 rule: HookRule, ev: Ev, armed: unknown, root: string, base: string, cwd: string, cache: Map<string, unknown>,
439 ): Promise<string | null> {
440 const path = join(base, 'state', `wt-${worktreeKey(root) ?? 'None'}.json`)
441 if (!cache.has(path)) {
442 const text = await this.io.readText(path)
443 let doc: unknown = {}
444 try {
445 doc = text === undefined ? {} : JSON.parse(text)
446 } catch {
447 doc = {}
448 }
449 cache.set(path, doc)
450 }
451 let wrote = false
452 const snapshot = JSON.parse(JSON.stringify(cache.get(path) ?? {})) as unknown
453 const engine = new OrderingEngine({
454 lock: () => true,
455 unlock: () => {},
456 read: () => snapshot,
457 write: () => {
458 wrote = true
459 },
460 })
461 const ok = ev.resp !== null ? bashOk(ev.resp, rule.mode === 'gate') : null
462 const armedArg = typeof armed === 'string' ? armed : armed === undefined ? null : (armed as string | null)
463 const local = engine.feed(rule, { hookPhase: ev.order_phase, tool: ev.tool, cmd: ev.cmd, filePath: ev.fp, ok, armed: armedArg })
464 if (!wrote) return local
465 delete rule._gate_msg
466 delete rule._legacy_fires
467 const r = await this.io.state([{
468 op: 'feed', root, rule: this.rules.find((x) => x.id === rule.id) ?? rule, hook_phase: ev.order_phase, tool: ev.tool,
469 cmd: ev.cmd, file_path: ev.fp, ok, armed: armedArg,
470 }], cwd)
471 cache.delete(path)
472 const got = r?.[0]
473 if (!isDict(got) || 'error' in got) return null
474 if (typeof got.gate_msg === 'string') rule._gate_msg = got.gate_msg
475 if (Array.isArray(got.legacy_fires)) rule._legacy_fires = got.legacy_fires
476 return typeof got.outcome === 'string' ? got.outcome : null
477 }
478
479 // ── file facts ───────────────────────────────────────────────────────────
480
481 private async realpath(p: string): Promise<string> {
482 return (await this.io.stat(p, true))?.realPath ?? p
483 }
484
485 /** `_worktrees(root)` */
486 private async worktrees(root: string): Promise<string[]> {
487 let r: { code: number; stdout: string }
488 try {
489 r = await this.io.git(['-C', root, 'worktree', 'list', '--porcelain'], root, 3000)
490 } catch {
491 return [root]
492 }
493 if (r.code !== 0) return [root]
494 const seen = [root]
495 const real = new Set([await this.realpath(root)])
496 for (const line of r.stdout.split(/\r\n|\r|\n/)) {
497 if (!line.startsWith('worktree ')) continue
498 const p = line.slice(9)
499 const rp = await this.realpath(p)
500 if (!real.has(rp)) {
501 seen.push(p)
502 real.add(rp)
503 }
504 }
505 return seen
506 }
507
508 /** `_names_of(path)` */
509 private async namesOf(path: string): Promise<string[]> {
510 const names = new Set([path, await this.realpath(path)])
511 for (const n of [...names]) if (n.startsWith('/private/')) names.add(n.slice('/private'.length))
512 return [...names]
513 }
514
515 /** `bash_written_files(root, cmd, since)` */
516 private async bashWrittenFiles(root: string, cmd: string, since: number): Promise<[string, boolean][]> {
517 if (!root || pySearch(TREE_REWRITE_RX, shellOnly(cmd || ''), 'm')) return []
518 const roots: string[] = []
519 for (const w of await this.worktrees(root)) {
520 if (w === root || (await this.namesOf(w)).some((n) => (cmd || '').includes(n))) roots.push(w)
521 }
522 const out: [string, boolean][] = []
523 for (const wt of roots) {
524 let r: { code: number; stdout: string }
525 try {
526 r = await this.io.git(['-C', wt, 'status', '--porcelain=v1', '-z', '--untracked-files=all'], wt, 5000)
527 } catch {
528 continue
529 }
530 if (r.code !== 0) continue
531 const entries = r.stdout.split('\0')
532 if (entries.length > BASH_EDIT_MAX_STATUS) continue
533 let skipNext = false
534 for (const e of entries) {
535 if (skipNext) {
536 skipNext = false
537 continue
538 }
539 if (e.length < 4) continue
540 const code = e.slice(0, 2)
541 const rel = e.slice(3)
542 skipNext = code[0] === 'R' || code[0] === 'C'
543 if (code.includes('D')) continue
544 const isNew = code === '??' || code[0] === 'A'
545 const path = join(wt, rel)
546 const st = await this.io.stat(path)
547 if (!st || st.kind !== 'file' || st.mtimeMs / 1000 < since) continue
548 out.push([path, isNew])
549 if (out.length > BASH_EDIT_MAX_FILES) return []
550 }
551 }
552 return out
553 }
554
555 /** `read_edit_body(path, is_new)` */
556 private async readEditBody(path: string, isNew: boolean): Promise<string | null> {
557 const st = await this.io.stat(path)
558 if (!st || st.size > BASH_EDIT_MAX_BYTES) return null
559 const raw = await this.io.readText(path)
560 if (raw === undefined || raw.includes('\0')) return null
561 if (isNew) return raw
562 let r: { code: number; stdout: string }
563 try {
564 const dir = path.slice(0, Math.max(path.lastIndexOf('/'), 0)) || (path.startsWith('/') ? '/' : '')
565 r = await this.io.git(['-C', dir, 'diff', 'HEAD', '--no-color', '--no-ext-diff', '-U0', '--', path], dir || '.', 5000)
566 } catch {
567 return null
568 }
569 if (r.code !== 0) return null
570 return r.stdout.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).map((l) => l.slice(1)).join('\n')
571 }
572
573 /** `read_facts(path, pulled, offset, limit)` */
574 private async readFacts(path: string, o: { pulled?: number | null; offset?: unknown; limit?: unknown }): Promise<Dict | null> {
575 const st = await this.io.stat(path)
576 if (!st || st.kind !== 'file') return null
577 const size = st.size
578 let total: number | undefined
579 if (size < FS_READ_MAX) {
580 const text = await this.io.readText(path)
581 if (text !== undefined) {
582 total = 0
583 for (let i = text.indexOf('\n'); i >= 0; i = text.indexOf('\n', i + 1)) total++
584 if (text.length && !text.endsWith('\n')) total++
585 }
586 }
587 if (total === undefined) total = await this.io.countLines(path)
588 if (total === undefined) return null
589 let n = total
590 if (o.offset !== undefined && o.offset !== null) {
591 const off = pyIntLoose(o.offset)
592 if (off !== null) n = Math.max(total - Math.max(off, 1) + 1, 0)
593 }
594 for (const cap of [o.limit, o.pulled]) {
595 if (cap === undefined || cap === null) continue
596 const c = pyIntLoose(cap)
597 if (c !== null) n = Math.min(n, Math.max(c, 0))
598 }
599 const bytes = n >= total ? size : total ? Math.trunc((size * n) / total) : 0
600 return { lines: n, bytes }
601 }
602
603 // ── pre / post ───────────────────────────────────────────────────────────
604
605 async pre(e: CallEvent): Promise<Verdict> {
606 return this.call('pre', e)
607 }
608
609 async post(e: CallEvent): Promise<Verdict> {
610 return this.call('post', e)
611 }
612
613 private async call(mode: 'pre' | 'post', e: CallEvent): Promise<Verdict> {
614 this.assertPortable()
615 const out: Verdict = { context: [], fires: [], ledger: [] }
616 if (!this.loaded) return out
617 const inp = (e.input ?? {}) as Dict
618 const info = await this.where(e.cwd, e.tool, inp)
619 if (!info) return out
620 const base = await this.baseOf(info.root)
621 const st = await this.state(e.sessionId, base)
622 const saved = cloneState(st)
623 try {
624 return await this.callInner(mode, e, inp, info, base, st, out)
625 } catch (err) {
626 if (err instanceof ForeignRepoError || err instanceof UnportableRuleError) throw err
627 // Python's main(): any exception is a silent exit 0, its state unsaved.
628 this.sessions.set(e.sessionId, saved)
629 return { context: [], fires: [], ledger: [] }
630 }
631 }
632
633 private async callInner(
634 mode: 'pre' | 'post', e: CallEvent, inp: Dict, info: RepoInfo, base: string, st: SessionState, out: Verdict,
635 ): Promise<Verdict> {
636 const io = this.io
637 const { repo, root, gitdir, branch } = info
638 const rules = this.book()
639 const env: EngineEnv = await io.env().catch(() => ({}))
640 const tool = e.tool
641 const session = e.sessionId
642 const cwd = e.cwd
643 const agentId = e.agentId && pyStrip(e.agentId) ? cpSlice(pyStrip(e.agentId), 0, 64) : null
644 const ctx: Ctx = { session, agent_id: agentId, repo, branch, tool, worktree: worktreeKey(root) }
645 const ledger = out.ledger
646 const cmdText = tool === 'Bash' ? pyStr(inp.command || '') : ''
647 let probeRoot = root
648 let probeBranch = branch
649 const elsewhere = await this.repos.commandRoot(cwd, cmdText)
650 if (elsewhere && elsewhere !== root) {
651 probeRoot = elsewhere
652 probeBranch = (await this.repos.repoInfo(elsewhere, false)).branch
653 }
654 const turns = this.turns()
655 const probes = new Probes(io, probeRoot, probeBranch, cmdText, agentId, turns, cwd, env.baseBranch ?? '')
656
657 await this.refreshArmings(st, session, base, rules)
658 const droppedArmings: string[] = []
659 const dropArming = (rid: string) => {
660 if (rid in st.armed || rid in st.armed_version) droppedArmings.push(rid)
661 delete st.armed[rid]
662 delete st.armed_version[rid]
663 }
664 let firedNow: HookRule[] = []
665
666 let cmd = tool === 'Bash' ? strOf(inp, 'command') : ''
667 let overrideReason: string | null = null
668 if (cmd) {
669 const found = findOverride(cmd)
670 if (found) {
671 overrideReason = cpSlice(redactSecrets(found[0]), 0, 2000)
672 cmd = stripOverride(cmd, found)
673 }
674 }
675 let overrideLabel: string | null = null
676 if (overrideReason !== null) {
677 const rawReason = overrideReason
678 ;[overrideLabel, overrideReason] = splitNamedOverride(overrideReason)
679 if (overrideLabel !== null && !overrideReason) {
680 overrideLabel = null
681 overrideReason = null
682 } else if (overrideLabel !== null &&
683 !rules.some((r) => overrideLabel === pyStr(r.id).toLowerCase() || overrideLabel === labelOf(r))) {
684 overrideLabel = null
685 overrideReason = rawReason
686 }
687 }
688 const fp = strOf(inp, 'file_path')
689 const edits = truthy(inp.edits) && Array.isArray(inp.edits) ? (inp.edits as unknown[]) : []
690 const body = strOf(inp, 'new_string') + strOf(inp, 'content') +
691 edits.filter(isDict).map((x) => strOf(x, 'new_string')).join('\n')
692 const added = editAddedText(inp)
693 const editMarkers = new Map<string, string>()
694 if (mode === 'pre' && EDIT_TOOLS.includes(tool)) {
695 for (const [k, v] of findEditOverride(body)) editMarkers.set(k, cpSlice(redactSecrets(v), 0, 2000))
696 }
697 const rtext = mode === 'post' ? e.result?.text ?? '' : ''
698 const resp = mode === 'post' && tool === 'Bash' ? { text: e.result?.text ?? '', isError: !!e.result?.isError } : null
699 const dedupKeys: Record<string, string> = {}
700
701 // The events this call is (main(): synthetic edits FIRST, then the call, then synthetic reads).
702 const real: Ev = { tool, phase: mode, order_phase: mode, cmd, fp, body, added, rtext, resp, via: null, read: null }
703 const events: Ev[] = []
704 if (tool === 'Bash') {
705 const marks = st.bash_t0
706 const callKey = e.toolUseId ? pyStr(e.toolUseId) : 'last'
707 if (mode === 'pre') {
708 marks.set(callKey, io.now() / 1000)
709 const keys = [...marks.keys()]
710 for (const k of keys.slice(0, Math.max(keys.length - BASH_EDIT_MARKS_KEPT, 0))) marks.delete(k)
711 } else {
712 let t0 = marks.get(callKey)
713 marks.delete(callKey)
714 if (t0 === undefined && callKey !== 'last') {
715 t0 = marks.get('last')
716 marks.delete('last')
717 }
718 const wantsEdits = rules.some((r) => (r.on === 'edit' || r.on === 'ordering') && isActive(r))
719 if (t0 !== undefined && wantsEdits) {
720 for (const [path, isNew] of await this.bashWrittenFiles(root, cmd, t0)) {
721 const text = await this.readEditBody(path, isNew)
722 if (text === null) continue
723 events.push({ tool: 'Write', phase: 'pre', order_phase: 'post', cmd: '', fp: path, body: text, rtext: '', resp: null, via: 'bash', read: null })
724 }
725 }
726 }
727 }
728 events.push(real)
729 const wantsReads = mode === 'pre' && rules.some((r) => r.on === 'read' && isActive(r))
730 if (wantsReads && READ_TOOLS.includes(tool) && fp) {
731 real.read = await this.readFacts(fp, { offset: inp.offset ?? null, limit: inp.limit ?? null })
732 } else if (wantsReads && tool === 'Bash' && cmd) {
733 for (const [path, pulled] of bashReads(cwd, cmd, io.home)) {
734 events.push({
735 tool: 'Read', phase: 'pre', order_phase: 'pre', cmd, fp: path, body: '', rtext: '', resp: null,
736 via: 'bash-read', read: await this.readFacts(path, { pulled }),
737 })
738 }
739 }
740
741 // Conversions: collected now, posted after the fire pass.
742 const convertedHits: string[] = []
743 if (mode === 'post' && Object.values(st.spec_pending).some((p) => isDict(p) && p.root === probeRoot && p.branch === probeBranch)) {
744 const changed = await probes.diffPaths()
745 if (changed !== null) {
746 for (const rule of rules) {
747 const pendingKey = pyDumps([rule.id, probeRoot, probeBranch])
748 const pending = st.spec_pending[pendingKey]
749 if (!(isDict(pending) && pending.root === probeRoot && pending.branch === probeBranch && truthy(pending.paths))) continue
750 const specDir = pyStr(getOr(pending, 'spec_dir', 'docs/specs'))
751 let all = true
752 for (const path of pending.paths as string[]) {
753 if (!changed.includes(path) || !((await probes.activeSpecPaths(specDir)) ?? new Set()).has(path)) {
754 all = false
755 break
756 }
757 }
758 if (all && scopeOk(rule, repo, gitdir)) {
759 convertedHits.push(rule.id)
760 delete st.spec_pending[pendingKey]
761 }
762 }
763 }
764 }
765 if (mode === 'post' && tool === 'Bash' && cmd) {
766 const stripped = stripComments(shellOnly(cmd))
767 for (const r of rules) {
768 const crx = r.converted_rx
769 if (truthy(crx) && isActive(r) && scopeOk(r, repo, gitdir)) {
770 let hit = false
771 try {
772 hit = pySearch(pyStr(crx), stripped, 'im')
773 } catch {
774 hit = false
775 }
776 if (hit) convertedHits.push(r.id)
777 }
778 }
779 }
780
781 const dismissals = new Map<string, string>()
782 if (mode === 'pre' && overrideLabel !== null) dismissals.set(overrideLabel, overrideReason!)
783
784 const marks: Marks = { fired: [...st.fired], counts: { ...st.counts }, spec_pending: { ...st.spec_pending } }
785
786 // Anchor rules (§4.7), matched locally.
787 let handle = ''
788 let apath = ''
789 if (tool === 'Bash' && cmd) handle = cpSlice(redactSecrets(shellOnly(cmd)), 0, 400)
790 else if (EDIT_TOOLS.includes(tool) && fp) handle = apath = fp
791 if (mode === 'pre' && handle && (env.recall ?? '1') !== '0') {
792 let n = 0
793 for (const r of rules) {
794 if (n >= MAX_ADVISE) break
795 if (r.on !== 'anchor' || !isActive(r) || st.fired.includes(r.id) || !scopeOk(r, repo, gitdir) ||
796 !pathInScope(sc(r), apath, root) || !anchorHits(sc(r), handle).length) continue
797 st.fired.push(r.id)
798 dedupKeys[r.id] = r.id
799 firedNow.push(r)
800 n += 1
801 }
802 }
803
804 const firedOn = new Map<string, Ev>()
805 const wtCache = new Map<string, unknown>()
806 for (const ev of events) {
807 const { tool: etool, phase: ephase, cmd: ecmd, fp: efp, body: ebody } = ev
808 for (let r of rules) {
809 if (r.on === 'session' || r.on === 'anchor' || !scopeOk(r, repo, gitdir) || !isActive(r)) continue
810 const rid = r.id
811 if (firedOn.has(rid)) continue
812
813 if (r.on === 'ordering') {
814 if (EDIT_TOOLS.includes(etool) && !pathInScope(sc(r), efp, root)) continue
815 if (rid in st.armed && rid in st.armed_version && (st.armed_version[rid] ?? null) !== (r._version ?? null)) dropArming(rid)
816 let outcome: string | null
817 try {
818 outcome = await this.feed(r, ev, st.armed[rid], root, base, cwd, wtCache)
819 } catch {
820 outcome = null
821 }
822 if (outcome === 'discharged') {
823 dropArming(rid)
824 ledger.push(this.eventRow(ctx, 'receipt', { rule_id: rid, worktree: sessionScoped(r) ? null : ctx.worktree }))
825 delete r._legacy_fires
826 } else if (outcome === 'fired') {
827 dedupKeys[rid] = `${rid}@${root}:${branch}`
828 firedNow.push(r)
829 firedOn.set(rid, ev)
830 }
831 continue
832 }
833
834 if (!pathInScope(sc(r), EDIT_TOOLS.includes(etool) || READ_TOOLS.includes(etool) ? efp : '', root)) continue
835 let scope = has(r, 'fire_scope') ? r.fire_scope : 'session'
836 if (ephase === 'pre' && r.mode === 'gate' && (ev.via === null || ev.via === 'bash-read') &&
837 ((etool === 'Bash' && r.on === 'bash') || (EDIT_TOOLS.includes(etool) && r.on === 'edit') ||
838 (READ_TOOLS.includes(etool) && r.on === 'read'))) {
839 scope = 'call'
840 }
841 if (typeof scope !== 'string') throw new TypeError("'NoneType' object has no attribute 'startswith'")
842 const key = !scope.startsWith('branch') ? rid : `${rid}:${branch}`
843 const matched = evaluate(r, { hookPhase: ephase, tool: etool, cmd: ecmd, filePath: efp, body: ebody, resultText: ev.rtext, added: ev.added ?? null }) &&
844 (await givenOk(r, probes, ev.read))
845 if (scope !== 'call' && !scope.startsWith('counter') && st.fired.includes(key)) {
846 if (matched) st.raw[rid] = (st.raw[rid] ?? 0) + 1
847 continue
848 }
849 if (!matched) continue
850 st.raw[rid] = (st.raw[rid] ?? 0) + 1
851 if (scope.startsWith('counter')) {
852 const i = scope.indexOf(':')
853 const threshold = (i >= 0 ? pyIntLoose(scope.slice(i + 1)) : null) ?? 1
854 st.counts[rid] = (st.counts[rid] ?? 0) + 1
855 if (st.counts[rid] !== threshold) continue
856 }
857 st.fired.push(key)
858 dedupKeys[rid] = key
859 const given = truthy(r.given) && isDict(r.given) ? r.given : {}
860 const specGiven = truthy(given.repo) && isDict(given.repo) ? given.repo : {}
861 if (truthy(specGiven.spec_untouched)) {
862 const specDir = pyStr(getOr(specGiven, 'spec_dir', 'docs/specs'))
863 const hits = (await probes.untouchedSpecs(specDir)) ?? []
864 st.spec_pending[pyDumps([rid, probeRoot, probeBranch])] = {
865 root: probeRoot, branch: probeBranch, spec_dir: specDir, paths: hits.map(([p]) => p),
866 }
867 r = { ...r }
868 const [text, detail] = specUntouchedText(pyStr(r.text), hits)
869 r.text = text
870 r._spec_detail = detail
871 }
872 firedNow.push(r)
873 firedOn.set(rid, ev)
874 }
875 }
876
877 // One instant for everything this call records.
878 const firedUs = this.nowUs()
879 const firedAt = this.iso(firedUs)
880 const firedIds = new Set(firedNow.map((r) => r.id))
881 for (const rid of convertedHits) {
882 ledger.push(this.eventRow(ctx, 'converted', { rule_id: rid, at: firedIds.has(rid) ? this.iso(firedUs - 1) : firedAt }))
883 }
884
885 const excerptOf = (r: HookRule): string => {
886 const ev = firedOn.get(r.id)
887 if (ev?.via === 'bash') return `bash-edit ${ev.fp}`
888 if (ev?.via === 'bash-read') return `bash-read ${ev.fp}`
889 return cmd || fp || ''
890 }
891
892 // The judge.
893 const [judged, held, fresh] = await judgeFires(io, st, {
894 repo, session, tool, cmd, fp, root, firedNow, firedOn,
895 enabled: (env.judge ?? '1') !== '0', timeoutMs: judgeTimeoutMs(env.timeoutS), base,
896 })
897 for (const r of firedNow.filter((r) => held.has(r.id))) {
898 if (fresh.has(r.id)) {
899 ledger.push(this.fireRow(ctx, r, mode, 'suppressed', excerptOf(r), {
900 raw: { [r.id]: st.raw[r.id] ?? null }, dedup: dedupKeys, at: firedAt, judge: judged,
901 }))
902 }
903 unfire(st, r, dedupKeys[r.id], marks, fresh.has(r.id))
904 }
905 firedNow = firedNow.filter((r) => !held.has(r.id))
906
907 const dismiss = (d: ReadonlyMap<string, string>): [[string, string][], [string, number][]] => {
908 const [resolved, ambiguous] = d.size ? resolveDismissals(rules, d) : [[], []]
909 const recorded: [string, string][] = []
910 for (const [r, w] of resolved) {
911 ledger.push(this.eventRow(ctx, 'dismissed', { rule_id: r.id, reason: w }))
912 recorded.push([pyStr(r._label || r.id), w])
913 }
914 return [recorded, ambiguous]
915 }
916
917 const finish = async () => {
918 if (droppedArmings.length) {
919 await io.state([{ op: 'drop', session, rule_ids: [...new Set(droppedArmings)] }], cwd).catch(() => undefined)
920 }
921 }
922
923 if (!firedNow.length) {
924 const [setAside, ambiguous] = dismiss(dismissals)
925 await finish()
926 if (setAside.length || ambiguous.length) {
927 const [agentLines] = dismissalLines(setAside, ambiguous)
928 out.context.push(agentLines.join('\n'))
929 }
930 return out
931 }
932
933 const fireOf = (r: HookRule) => firedOn.get(r.id)
934 const gateable = (r: HookRule): boolean => {
935 if (mode !== 'pre' || r.mode !== 'gate') return false
936 const via = fireOf(r)?.via ?? null
937 if (via === 'bash') return false
938 if (tool === 'Bash') return r.on === 'bash' || r.on === 'ordering' || (r.on === 'read' && via === 'bash-read')
939 if (READ_TOOLS.includes(tool)) return r.on === 'read'
940 return EDIT_TOOLS.includes(tool) && r.on === 'edit'
941 }
942 const gateIds = new Set(firedNow.filter(gateable).map((r) => r.id))
943
944 let overridden: Record<string, string> = {}
945 const gatesHere = firedNow.filter((r) => gateIds.has(r.id))
946 const labelCount = new Map<string, number>()
947 for (const r of gatesHere) labelCount.set(labelOf(r), (labelCount.get(labelOf(r)) ?? 0) + 1)
948 let ambiguousGate: [string, number] | null = null
949 const namedGates = (label: string) => namedRules(label, rules, gatesHere)
950 if (overrideReason !== null && overrideLabel === null) {
951 overridden = Object.fromEntries(gatesHere.map((r) => [r.id, overrideReason!]))
952 } else if (overrideReason !== null) {
953 const named = namedGates(overrideLabel!)
954 if (named.length === 1) {
955 overridden[named[0]!.id] = overrideReason
956 dismissals.delete(overrideLabel!)
957 } else if (named.length) {
958 ambiguousGate = [overrideLabel!, named.length]
959 dismissals.delete(overrideLabel!)
960 }
961 } else if (editMarkers.size) {
962 for (const [label, w] of editMarkers) {
963 const named = label ? namedGates(label) : []
964 if (named.length === 1) overridden[named[0]!.id] = w
965 else if (named.length) ambiguousGate = [label, named.length]
966 }
967 }
968 const gates = firedNow.filter((r) => gateIds.has(r.id))
969 const advisories = firedNow
970 .filter((r) => !gateIds.has(r.id))
971 .map((r, i) => ({ r, i, k: [r.on === 'anchor' ? 0 : 1, ...bookRank(r)] as number[] }))
972 .sort((a, b) => cmpTuple(a.k, b.k) || a.i - b.i)
973 .map((x) => x.r)
974 const shown = [...gates, ...advisories.slice(0, MAX_ADVISE)]
975 const cut = advisories.slice(MAX_ADVISE)
976 if (overrideLabel !== null && namedRules(overrideLabel, rules, firedNow).length) dismissals.delete(overrideLabel)
977 const [setAside, ambiguous] = dismiss(dismissals)
978 const sameCall: Record<string, string> = {}
979 const gateTookIt = overrideLabel !== null ? namedRules(overrideLabel, rules, gates).some((r) => r.id in overridden) : false
980 if (overrideLabel !== null && !gateTookIt) {
981 const here = namedRules(overrideLabel, rules, shown.filter((r) => !gateIds.has(r.id)))
982 if (here.length === 1) {
983 sameCall[here[0]!.id] = overrideReason!
984 const ack: [string, string] = [pyStr(here[0]!._label || here[0]!.id), overrideReason!]
985 if (!setAside.some(([a, b]) => a === ack[0] && b === ack[1])) setAside.push(ack)
986 } else if (here.length) {
987 ambiguous.push([overrideLabel, here.length])
988 }
989 }
990 const blocked = gates.some((r) => !(r.id in overridden))
991 const lines = [blocked ? `## ${BRAND} Rulebook — BLOCKED` : `## ${BRAND} Rulebook (team rules — advisory, not blocking)`]
992 const denyLines: string[] = []
993 if (setAside.length || ambiguous.length) lines.push(...dismissalLines(setAside, ambiguous)[0])
994
995 const whereOf = (r: HookRule): string => {
996 const ev = firedOn.get(r.id)
997 if (!ev || (ev.via !== 'bash' && ev.via !== 'bash-read')) return ''
998 let path = ev.fp
999 if (root && path.startsWith(root.replace(/\/+$/, '') + '/')) path = relpath(path, root)
1000 if (ev.via === 'bash-read') {
1001 const n = (ev.read ?? {}).lines
1002 return n !== undefined && n !== null ? ` _(\`${path}\`, ${pyStr(n)} lines, read by that command)_` : ` _(\`${path}\`, read by that command)_`
1003 }
1004 return ` _(in \`${path}\`, written by that command)_`
1005 }
1006
1007 for (const r of shown) {
1008 const label = pyStr(r._label || r.id)
1009 const detail = truthy(r._gate_msg) ? ` — ${pyStr(r._gate_msg)}` : ''
1010 const staleKey = `_degraded:${r.id}`
1011 let note = ''
1012 if (truthy(r._degraded) && !st.fired.includes(staleKey)) {
1013 st.fired.push(staleKey)
1014 note = ` _(advice only — ${pyStr(r._degraded)}. Update the ${BRAND} plugin to let this rule gate.)_`
1015 }
1016 const blockedHere = gateIds.has(r.id) && !(r.id in overridden)
1017 const specDetail = pyStr(r._spec_detail ?? '')
1018 const text = pyStr(r.text)
1019 if (!gateIds.has(r.id)) {
1020 lines.push(`- **[${label}]** ${text}${detail}${whereOf(r)}${why(r)}${specDetail}`)
1021 } else if (r.id in overridden) {
1022 lines.push(`- **[${label}]** ${text}${detail}${whereOf(r)}${why(r)}${specDetail} _(gate overridden: ${overridden[r.id]})_`)
1023 } else {
1024 lines.push(`- **BLOCKED [${label}]** ${text}${detail}${whereOf(r)}${why(r)}${specDetail}`)
1025 const ident = (labelCount.get(labelOf(r)) ?? 0) > 1 ? ` (rule id ${r.id})` : ''
1026 denyLines.push(`[${label}]${ident} ${text}${detail}${whereOf(r)}`)
1027 }
1028 if (note) lines.push(note)
1029 const line = disclosureLine(r, blockedHere)
1030 const f: Fire = { ruleId: r.id, label, line, mode: gateIds.has(r.id) ? 'gate' : 'advise', text }
1031 out.fires.push(f)
1032 }
1033 if (shown.some((r) => !gateIds.has(r.id))) lines.push(ADVISE_FEEDBACK_HINT)
1034 if (blocked) {
1035 const still = gates.filter((r) => !(r.id in overridden))
1036 let how: string
1037 if (tool === 'Bash') {
1038 how = "re-run the same command prefixed RULEBOOK_OVERRIDE='<why>' — that allows exactly that call and records why"
1039 } else if (READ_TOOLS.includes(tool)) {
1040 how = 'read only the part you need (`offset`/`limit`), or hand the question to a ' +
1041 'subagent so its answer, not the file, enters this context; if the whole ' +
1042 "file must be read here, run RULEBOOK_OVERRIDE='<why>' cat <path> in Bash — " +
1043 'that allows exactly that read and records why'
1044 } else {
1045 const named = still.map((r) => `\`rulebook-override[${pyStr(r._label || r.id)}]: <why>\``).join(', ')
1046 how = `add a comment naming the rule you are excusing (${named}) — each allows ` +
1047 'that one rule, records why, and stays in the diff for the next reader'
1048 if (editMarkers.has('')) {
1049 how += '. A `rulebook-override:` with no rule in brackets excuses nothing — ' +
1050 'it would mean something different as soon as a second edit gate ' +
1051 'covers this line'
1052 }
1053 }
1054 if (ambiguousGate) {
1055 how = `\`[${ambiguousGate[0]}]\` fits ${ambiguousGate[1]} of this call's gates, so it ` +
1056 'excused none — name the one you mean by its rule id, ' +
1057 `\`[<rule id>] <why>\`, in the same override form; ${how}`
1058 }
1059 out.deny = `Blocked by the ${BRAND} team rulebook:\n` + denyLines.map((l) => `- ${l}`).join('\n') +
1060 `\nIf this is a legitimate exception, ${how}.`
1061 lines.push(`_This call was blocked. If it is a legitimate exception, ${how}._`)
1062 }
1063 out.context.push(lines.join('\n'))
1064
1065 const raw: Record<string, unknown> = Object.fromEntries(firedNow.map((r) => [r.id, st.raw[r.id] ?? null]))
1066 for (const r of shown.filter((r) => !gateIds.has(r.id))) {
1067 ledger.push(this.fireRow(ctx, r, mode, 'advise', excerptOf(r), { raw, dedup: dedupKeys, at: firedAt, judge: judged }))
1068 }
1069 for (const r of gates) {
1070 ledger.push(this.fireRow(ctx, r, mode, 'gate', cmd || fp || '', {
1071 raw, dedup: dedupKeys, override: overridden, at: firedAt, judge: judged,
1072 }))
1073 }
1074 for (const r of cut) {
1075 ledger.push(this.fireRow(ctx, r, mode, 'suppressed', excerptOf(r), { raw, dedup: dedupKeys, at: firedAt, judge: judged }))
1076 }
1077 for (const r of shown) {
1078 st.raw[r.id] = 0
1079 if (r.id in sameCall) ledger.push(this.eventRow(ctx, 'dismissed', { rule_id: r.id, reason: sameCall[r.id]!, at: firedAt }))
1080 }
1081 await finish()
1082 return out
1083 }
1084
1085 // ── arming (prompt / session) ────────────────────────────────────────────
1086
1087 /** `arm_obligations(rules, repo, gitdir, session, event, prompt)`, written through the Python's lock. */
1088 private async arm(
1089 rules: readonly HookRule[], info: RepoInfo, session: string, event: 'prompt' | 'session', prompt: string, cwd: string, st: SessionState,
1090 ): Promise<string[]> {
1091 const arming = rules.filter((r) => isActive(r) && scopeOk(r, info.repo, info.gitdir) && armsOn(r, event, prompt))
1092 if (!arming.length) return []
1093 const r = await this.io.state([{
1094 op: 'arm', session, event, prompt, rules: arming.map((x) => this.rules.find((y) => y.id === x.id) ?? x),
1095 repo: info.repo, gitdir: info.gitdir,
1096 }], cwd)
1097 const got = r?.[0]
1098 if (!isDict(got) || !Array.isArray(got.armed)) return []
1099 const armed = got.armed as string[]
1100 for (const rid of armed) {
1101 if (event === 'session' && !st.armed_once.includes(`session:${rid}`)) st.armed_once.push(`session:${rid}`)
1102 if (!(rid in st.armed)) st.armed[rid] = event
1103 st.armed_version[rid] = arming.find((x) => x.id === rid)?._version ?? null
1104 }
1105 return armed
1106 }
1107
1108 // ── prompt ───────────────────────────────────────────────────────────────
1109
1110 async prompt(text: string, sessionId: string, cwd: string): Promise<Verdict> {
1111 this.assertPortable()
1112 const out: Verdict = { context: [], fires: [], ledger: [] }
1113 if (!this.loaded || !text) return out
1114 const info = await this.where(cwd, '', {})
1115 if (!info) return out
1116 const base = await this.baseOf(info.root)
1117 const st = await this.state(sessionId, base)
1118 const saved = cloneState(st)
1119 try {
1120 const rules = this.book()
1121 if (!harnessPrompt(text)) await this.arm(rules, info, sessionId, 'prompt', text, cwd, st)
1122 if (!agentMessage(text)) await this.promptLane(rules, info, sessionId, cwd, text, st, out)
1123 return out
1124 } catch {
1125 this.sessions.set(sessionId, saved)
1126 return { context: [], fires: [], ledger: [] }
1127 }
1128 }
1129
1130 /** `prompt_lane(...)`: the `prompt` matcher rules, advise only. */
1131 private async promptLane(rules: HookRule[], info: RepoInfo, session: string, cwd: string, text: string, st: SessionState, out: Verdict) {
1132 const { repo, root, gitdir, branch } = info
1133 const live = rules.filter((r) => r.on === 'prompt' && isActive(r) && scopeOk(r, repo, gitdir) && pathInScope(sc(r), '', root))
1134 if (!live.length) return
1135 const env: EngineEnv = await this.io.env().catch(() => ({}))
1136 const ctx: Ctx = { session, agent_id: null, repo, branch, tool: 'UserPromptSubmit', worktree: worktreeKey(root) }
1137 const probes = new Probes(this.io, root, branch, '', null, this.turns(), cwd, env.baseBranch ?? '')
1138 const fired: HookRule[] = []
1139 const dedupKeys: Record<string, string> = {}
1140 for (const r of live) {
1141 const rid = r.id
1142 const scope = has(r, 'fire_scope') ? r.fire_scope : 'session'
1143 if (typeof scope !== 'string') throw new TypeError("'NoneType' object has no attribute 'startswith'")
1144 const key = !scope.startsWith('branch') ? rid : `${rid}:${branch}`
1145 const matched = evaluate(r, { hookPhase: 'prompt', tool: 'UserPromptSubmit', prompt: text }) && (await givenOk(r, probes))
1146 if (scope !== 'call' && st.fired.includes(key)) {
1147 if (matched) st.raw[rid] = (st.raw[rid] ?? 0) + 1
1148 continue
1149 }
1150 if (!matched) continue
1151 st.raw[rid] = (st.raw[rid] ?? 0) + 1
1152 if (scope !== 'call') st.fired.push(key)
1153 dedupKeys[rid] = key
1154 fired.push(r)
1155 }
1156 if (!fired.length) return
1157 const sorted = fired.map((r, i) => ({ r, i })).sort((a, b) => cmpTuple(bookRank(a.r), bookRank(b.r)) || a.i - b.i).map((x) => x.r)
1158 const shown = sorted.slice(0, MAX_ADVISE)
1159 const cut = sorted.slice(MAX_ADVISE)
1160 const lines = [`## ${BRAND} Rulebook (team rules — advisory, not blocking)`]
1161 for (const r of shown) {
1162 const label = pyStr(r._label || r.id)
1163 lines.push(`- **[${label}]** ${pyStr(r.text)}${why(r)}`)
1164 const staleKey = `_degraded:${r.id}`
1165 if (truthy(r._degraded) && !st.fired.includes(staleKey)) {
1166 st.fired.push(staleKey)
1167 lines.push(` _(advice only — ${pyStr(r._degraded)}. Update the ${BRAND} plugin to let this rule gate.)_`)
1168 }
1169 out.fires.push({ ruleId: r.id, label, line: disclosureLine(r), mode: 'advise', text: pyStr(r.text) })
1170 }
1171 lines.push(ADVISE_FEEDBACK_HINT)
1172 out.context.push(lines.join('\n'))
1173 const firedAt = this.iso(this.nowUs())
1174 const raw: Record<string, unknown> = Object.fromEntries(sorted.map((r) => [r.id, st.raw[r.id] ?? null]))
1175 for (const r of shown) {
1176 const m = pyExec(pyStr(r.rx), text, 'im')
1177 out.ledger.push(this.fireRow(ctx, r, 'prompt', 'advise', m ? m[0] : '', { raw, dedup: dedupKeys, at: firedAt }))
1178 }
1179 for (const r of cut) out.ledger.push(this.fireRow(ctx, r, 'prompt', 'suppressed', '', { raw, dedup: dedupKeys, at: firedAt }))
1180 for (const r of shown) st.raw[r.id] = 0
1181 }
1182
1183 // ── session ──────────────────────────────────────────────────────────────
1184
1185 async session(sessionId: string, cwd: string, opts: { servedAlready?: boolean } = {}): Promise<{ context: string[]; ledger?: Record<string, unknown>[] }> {
1186 if (!this.loaded) return { context: [] }
1187 const info = await this.where(cwd, '', {})
1188 if (!info) return { context: [] }
1189 const base = await this.baseOf(info.root)
1190 const st = await this.state(sessionId, base)
1191 const rules = this.book()
1192 const ledger: Record<string, unknown>[] = []
1193 const context: string[] = []
1194 if (!opts.servedAlready) {
1195 const env: EngineEnv = await this.io.env().catch(() => ({}))
1196 const text = this.digest(rules, info, sessionId, env, ledger)
1197 if (text) context.push(text)
1198 }
1199 await this.arm(rules, info, sessionId, 'session', '', cwd, st)
1200 return { context, ledger }mod/lanes.ts 267 lines1// The Rulebook lanes on mod events (spec §4.2–§4.5): each builds what the
2// engine needs, asks it, and hands the Verdict to act.ts. A lane that is not
3// claimed (claims.ts) is NOT evaluated here — Python serves it, and serving
4// it twice would double-fire (outside the brief overlaps claims.ts accepts).
5//
6// pre + post tool.call (Bash, Edit, MultiEdit, Write, NotebookEdit, Read):
7// pre before `next`; a gate answers `{ deny }` WITHOUT `next`
8// (so nothing beneath runs, spec §1.2); otherwise
9// `r = await next(e)`, post on `postResultText(tool, r)`
10// / `r.isError`, and `r`
11// comes back with the pre advisories and the post context added
12// to `r.context`.
13// prompt prompt.submit: `next({ ...e, context: [...e.context, text] })`.
14// session the posture preamble as an isMeta user row
15// (`$.session.append`, spec §4.5), once per session id.
16//
17// Failure: a lane that throws gives the call to Python. The pre and prompt
18// lanes leave the claim before their `next`, so the Python hook beneath serves
19// that very call; the post lane has run past Python's PostToolUse by then (a
20// post rule only advises) and counts the failure. A pre hand-off still runs
21// the mod's post lane after `next` while `post` is claimed: Python skipped
22// its PostToolUse for that lane, so nothing else would. A claims write that
23// fails during a hand-off does not cost the call: it still reaches `next`
24// (claims.ts unsets the variable, so Python serves). See claims.ts.
25
26import type { LaneName } from '../types'
27import type { Act } from './act'
28import type { Claims } from './claims'
29import type { Io } from './ctx'
30import type { CallEvent, Engine, Verdict } from './engine'
31
32export const TOOLS = ['Bash', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Read'] as const
33/**
34 * The matcher for TOOLS. A RegExp, not the list: this build has no
35 * `MultiEdit` built-in (claude-code.d.ts BuiltinToolName), so the list does
36 * not type; the pattern still catches it on a build that has one.
37 */
38export const TOOL_RX = /^(?:Bash|Edit|MultiEdit|Write|NotebookEdit|Read)$/
39
40/** What a `tool.call` hook sees, as far as the lanes read it. */
41export type ToolCallLike = { tool: string; tool_use_id?: string; agentId?: string; [k: string]: unknown }
42/** What a `tool.call` resolves to, as far as the lanes read it (ToolCallResult). */
43export type ToolResultLike =
44 | { deny: string; context?: undefined; text?: undefined; isError?: undefined }
45 | { deny?: undefined; context?: readonly string[]; text?: string; isError?: true; [k: string]: unknown }
46export type PromptLike = { text: string; context?: readonly string[]; [k: string]: unknown }
47
48/** The `tool_response` keys rulebook_hook.py `result_text` joins, in its order. */
49const RESULT_KEYS = ['stderr', 'stdout', 'output', 'error', 'text'] as const
50
51/**
52 * The text the post lane's result rules match: for Bash, built from the
53 * tool's own record the way rulebook_hook.py `result_text(tool_response)`
54 * builds it from Claude Code's PostToolUse payload: the non-empty string
55 * fields among RESULT_KEYS, in that order (stderr before stdout), joined by
56 * a newline. The engine then scans both ends of it (rules.ts, two
57 * RESULT_WINDOW_CHARS spans), as Python's `evaluate` does, and an advise-mode
58 * ordering receipt's text check reads it too, as `bash_ok` reads
59 * `result_text`.
60 *
61 * Why not `r.text`: when Claude Code persists a large output to a file, the
62 * model, and `r.text`, get only a ~2 KB `<persisted-output>` preview, while
63 * the record's `stdout` (the same field PostToolUse sends as
64 * `tool_response.stdout`) still holds the first ~30,000 characters. A rule
65 * matching past the preview fired in Python and not here.
66 *
67 * Falls back to `r.text` for every other tool, for a record that is not an
68 * object (an error result is the error string), and for a record with no
69 * non-empty field: Python would match `json.dumps(tool_response)` there,
70 * which the mod does not reproduce (engine.ts, port notes).
71 */
72export function postResultText(tool: string, r: { text?: string; result?: unknown }): string | undefined {
73 if (tool !== 'Bash') return r.text
74 const rec = r.result
75 if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) return r.text
76 const parts = RESULT_KEYS
77 .map(k => (rec as Record<string, unknown>)[k])
78 .filter((v): v is string => typeof v === 'string' && v !== '')
79 return parts.length ? parts.join('\n') : r.text
80}
81
82/** The tools whose rules scope by path, and the input fields the engine
83 * reads the path from (engine.ts `file_path`, repo.ts `notebook_path`). */
84const PATH_TOOLS: readonly string[] = ['Edit', 'Write', 'NotebookEdit', 'Read']
85const PATH_FIELDS = ['file_path', 'notebook_path'] as const
86
87export class Lanes {
88 /** Session ids whose posture preamble is in their conversation (by us or by Python). */
89 private served = new Set<string>()
90 /** `<session>\0<tool>` already reported by `noPathCheck`. */
91 private noPathSeen = new Set<string>()
92
93 constructor(
94 private io: Io,
95 private engine: Engine,
96 private claims: Claims,
97 private act: Act,
98 ) {}
99
100 // ── tool.call ───────────────────────────────────────────────────────────
101
102 /**
103 * A self-check on the event's shape, which only hand-written fakes pin: an
104 * Edit, Write, NotebookEdit or Read call carrying none of PATH_FIELDS means
105 * Claude Code renamed the field, and every path-scoped rule has silently
106 * stopped matching. One debug-log line per session per tool says so;
107 * nothing else changes (the call is evaluated exactly as before).
108 */
109 private noPathCheck(tool: string, input: Record<string, unknown>, sessionId: string): void {
110 if (!PATH_TOOLS.includes(tool)) return
111 if (PATH_FIELDS.some(k => typeof input[k] === 'string' && input[k] !== '')) return
112 const key = `${sessionId}\0${tool}`
113 if (this.noPathSeen.has(key)) return
114 this.noPathSeen.add(key)
115 try {
116 this.io.debug(`rulebook: a ${tool} tool.call carried no ${PATH_FIELDS.join(' or ')} ` +
117 `(input keys: ${Object.keys(input).sort().join(', ') || 'none'}); path-scoped rules cannot match it`)
118 } catch {
119 // `$.ui.log` is synchronous and may throw: a self-check costs nothing.
120 }
121 }
122
123 async toolCall<R extends ToolResultLike>(
124 e: ToolCallLike,
125 next: (e: ToolCallLike) => Promise<R>,
126 ): Promise<R | { deny: string }> {
127 if (!this.claims.has('pre') && !this.claims.has('post')) return next(e)
128 const { tool, tool_use_id: toolUseId, agentId, ...input } = e
129 const [sessionId, cwd] = await Promise.all([this.io.sessionId(), this.io.cwd()])
130 this.noPathCheck(tool, input, sessionId)
131 const base: Omit<CallEvent, 'phase'> = {
132 tool,
133 input,
134 sessionId,
135 cwd,
136 ...(agentId ? { agentId } : {}),
137 ...(toolUseId ? { toolUseId } : {}),
138 }
139
140 let pre: Verdict | undefined
141 let handed: R | undefined
142 if (this.claims.has('pre')) {
143 try {
144 pre = await this.engine.pre({ ...base, phase: 'pre' })
145 this.act.healthy('pre')
146 } catch (err) {
147 // Python's hook serves this call's pre at `next`; the post lane below
148 // still runs here, as it would have without the failure.
149 handed = await this.handOff('pre', err, () => next(e))
150 }
151 if (pre) {
152 await this.act.record(pre, sessionId)
153 if (pre.deny) return { deny: this.act.denyText(pre) }
154 }
155 }
156
157 const r = handed ?? (await next(e))
158 if (r.deny !== undefined) return r
159
160 let post: Verdict | undefined
161 if (this.claims.has('post')) {
162 try {
163 post = await this.engine.post({ ...base, phase: 'post', result: { text: postResultText(tool, r), isError: r.isError } })
164 this.act.healthy('post')
165 await this.act.record(post, sessionId)
166 } catch (err) {
167 // Python's PostToolUse already ran (skipped, the lane being ours):
168 // this call's post advice is lost, never a gate. Count it.
169 this.act.unhealthy('post', err)
170 await this.claims.fail('post').catch(() => undefined)
171 await this.claims.recover('post').catch(() => undefined)
172 post = undefined
173 }
174 }
175
176 const extra = [this.act.modelText(pre), this.act.modelText(post)].filter((t): t is string => !!t)
177 if (!extra.length) return r
178 return { ...r, context: [...(r.context ?? []), ...extra] }
179 }
180
181 // ── prompt.submit ───────────────────────────────────────────────────────
182
183 async promptSubmit<R>(e: PromptLike, next: (e: PromptLike) => Promise<R>): Promise<R> {
184 const sessionId = await this.io.sessionId()
185 await this.deliverSession(sessionId)
186 if (!this.claims.has('prompt')) return next(e)
187 let v: Verdict
188 try {
189 v = await this.engine.prompt(e.text, sessionId, await this.io.cwd())
190 this.act.healthy('prompt')
191 } catch (err) {
192 return this.handOff('prompt', err, () => next(e))
193 }
194 await this.act.record(v, sessionId)
195 const text = this.act.modelText(v)
196 return next(text ? { ...e, context: [...(e.context ?? []), text] } : e)
197 }
198
199 // ── session ─────────────────────────────────────────────────────────────
200
201 /**
202 * At session.start. `servedAlready`: the session that is starting already
203 * has its preamble — from Python's SessionStart hook in a fresh process
204 * (it ran before the lane could be claimed), or from the module before a
205 * hot reload. The engine still sees the session start, for its own state.
206 */
207 async sessionStart(servedAlready: boolean): Promise<void> {
208 const sessionId = await this.io.sessionId()
209 if (servedAlready) {
210 this.served.add(sessionId)
211 if (this.claims.has('session') || this.claims.has('pre')) {
212 await this.engine.session(sessionId, await this.io.cwd(), { servedAlready: true }).catch(() => undefined)
213 }
214 return
215 }
216 await this.deliverSession(sessionId)
217 }
218
219 /** After a compaction the preamble is summarised away: the next prompt brings it back (Python: SessionStart `compact`). */
220 compacted(sessionId: string): void {
221 this.served.delete(sessionId)
222 }
223
224 /**
225 * The posture preamble for a session that has none yet — in practice a
226 * compacted conversation. A new id after `/clear` or a resume is NOT served
227 * here: its claim stamp mismatched when Python's SessionStart ran, so Python
228 * delivered it, and claims' re-stamp marks the id served (no second copy).
229 * Once per session id; only while the lane is ours.
230 */
231 async deliverSession(sessionId: string): Promise<void> {
232 if (!this.claims.has('session') || this.served.has(sessionId)) return
233 this.served.add(sessionId)
234 try {
235 const { context, ledger } = await this.engine.session(sessionId, await this.io.cwd())
236 const text = context.join('\n')
237 if (text.trim()) await this.io.append('user', text)
238 // The posture rules' fire rows (Python's session_digest logs them as it emits).
239 if (ledger?.length) await this.act.record({ context: [], fires: [], ledger }, sessionId)
240 this.act.healthy('session')
241 } catch (err) {
242 // Not delivered: let Python's next SessionStart (a /clear, a compaction) serve it.
243 this.served.delete(sessionId)
244 this.act.unhealthy('session', err)
245 await this.claims.fail('session')
246 await this.claims.recover('session')
247 }
248 }
249
250 /**
251 * A lane threw before its `next`: out of the claim first, so the Python
252 * hook the engine starts at `next` serves this call; back in afterwards
253 * unless that was the lane's second failure.
254 */
255 private async handOff<T>(lane: LaneName, err: unknown, next: () => Promise<T>): Promise<T> {
256 this.act.unhealthy(lane, err)
257 // A rejected write still left the module serving nothing and the variable
258 // unset if it could be (claims.ts writeFailed): carry on to `next`.
259 await this.claims.fail(lane).catch((e: unknown) => this.act.unhealthy(lane, e))
260 try {
261 return await next()
262 } finally {
263 await this.claims.recover(lane).catch(() => undefined)
264 }
265 }
266}
267companion/animal.ts 143 lines1// The contract between the director (register.ts, which watches the session)
2// and one animal (its art, its poses, its choreography). The director knows
3// nothing of an animal beyond this file: not its size, its palette, or what
4// its frames hold. See docs/companion/ANIMALS.md for how to write one.
5
6import type { Canvas, RGB } from './pixels'
7
8/**
9 * Why the animal is speaking; the bubble's colour follows it. `proposed` is a
10 * new rule waiting to be activated or rejected: every animal presents it with
11 * a prelude of its own and holds it at least 8 s with the `proposed` tone, so
12 * the Activate / Reject / Later buttons can sit inside its bubble. An animal
13 * with no prelude still says it in the `proposed` tone, never as `advice`.
14 */
15export type Tone = 'advice' | 'blocked' | 'proposed'
16
17/** One character drawn over the pixels, at canvas pixel (x, y). */
18export type Glyph = { x: number; y: number; ch: string; color: RGB }
19
20/** What the animal is saying this frame, as the bubble should show it. */
21export type Bubble = {
22 text: string
23 /** How much of `text` has been typed out so far; `text.length` when done. */
24 shown: number
25 /** The bubble's corner label: `rule`, `tip 3`, `grr`. */
26 tag: string
27 tone: Tone
28}
29
30/** One drawn frame of an animal. */
31export type Painted = {
32 canvas: Canvas
33 /** Text over the pixels, such as a sleeper's z's. */
34 glyphs?: readonly Glyph[]
35 /** The speech bubble, or nothing while the animal is silent. */
36 bubble?: Bubble | null
37}
38
39/**
40 * The poses the director asks for, each a generator of the animal's own
41 * frame type. Four are finite and play whole; `sleep` and `look` are endless
42 * and the director cuts them off; `speak` and `pet` end on their own.
43 *
44 * The director only ever walks this ring:
45 *
46 * enter → sleep ⇄ (wake → look ⇄ pet) → rise → speak+ → leave → enter
47 *
48 * so every finite pose must end where the next one begins.
49 */
50export type Poses<F> = {
51 /** Offscreen → asleep. Runs at startup and after every `leave`. */
52 enter(): Generator<F>
53 /** Asleep, endless: nothing is happening in the session. */
54 sleep(): Generator<F>
55 /** Asleep → awake. */
56 wake(): Generator<F>
57 /** Awake, endless: Claude is answering, or the person is typing. */
58 look(): Generator<F>
59 /** Awake → the speaking position. */
60 rise(): Generator<F>
61 /**
62 * Says one thing and holds it long enough to read: type `text` out, then
63 * keep still for a few seconds. Several may run back to back, so this must
64 * start and end in the same position `rise` left the animal in.
65 *
66 * @param text what to say, already short enough for the bubble
67 * @param n how many times the animal has spoken this session, from 1
68 */
69 speak(text: string, n: number, tone: Tone): Generator<F>
70 /** The speaking position → offscreen, ready for `enter` again. */
71 leave(): Generator<F>
72 /**
73 * Awake → awake: what a click on the animal does. 20–40 frames, starting
74 * and ending on the first frame `look` yields. Without it the director
75 * floats hearts over `look` instead.
76 */
77 pet?(): Generator<F>
78}
79
80/**
81 * One build of an animal: art drawn for one pixel size, with the poses and
82 * the canvas that go with it.
83 *
84 * A pixel is `pixelSize` cells wide and half that tall, so the same drawing at
85 * size 1 packs two canvas rows into one cell and at size 2 gives each row a
86 * cell of its own. That is a different drawing problem, not the same one
87 * scaled: what reads at size 2 can turn to mush at size 1. An animal may ship
88 * a build per size, each with art of its own.
89 */
90export type Build<F = unknown> = {
91 /** Cells per pixel this art is drawn for. */
92 pixelSize: number
93 /** The canvas every frame paints on, in pixels (two pixels a terminal row). */
94 size: { columns: number; rows: number }
95 /**
96 * Where the bubble hangs, in canvas pixels: its right edge sits at
97 * `column`, and its last row at `row` — so the tail points at the animal's
98 * head while it speaks. `rowWhenAbove` is that last row for a terminal too
99 * narrow to fit the bubble beside the animal, where it sits over its head
100 * instead (the animal's own top row suits); `row` is used when absent.
101 */
102 bubbleAt: { column: number; row: number; rowWhenAbove?: number }
103 /**
104 * Canvas rows at the top and bottom the band may drop when it is short of
105 * room — empty sky, the base of a mound — so the pixels can be drawn bigger
106 * instead. Nothing the animal needs to read should live in them.
107 */
108 trim?: { top?: number; bottom?: number }
109 poses: Poses<F>
110 /**
111 * Draws one frame. Called exactly once per frame, in order, so a build may
112 * keep state between calls (particles, a wind, a ripple).
113 *
114 * @param frame what a pose just yielded
115 * @param tick the frame number since the session started, for anything
116 * that moves on its own (a shimmer, a drift)
117 */
118 render(frame: F, tick: number): Painted
119 /**
120 * What `/<name> demo` and `/<name> <pose>` replay, when the build has a loop
121 * of its own to show off; without it those arguments are refused.
122 */
123 demo?(only?: string | null): Generator<F>
124 /** The poses `demo` accepts by name, for the command's usage line. */
125 demoPoses?: readonly string[]
126}
127
128/**
129 * One animal: its name, and its art. The band draws the smallest build unless
130 * someone asks for a bigger pixel, which is what `/<name> scale <n>` is for.
131 */
132export type Animal = {
133 /** Its name, which is also its command: `Hippo` gives `/hippo`. */
134 name: string
135 /**
136 * What the speech bubble's header shows, when that is not just the name:
137 * the hippo answers to `/hippo` and introduces itself as Hugo.
138 */
139 title?: string
140 /** One build per pixel size, smallest first; at least one. */
141 builds: readonly Build[]
142}
143companion/animals/index.ts 17 lines1// Every animal the band can show. A new one lands here and nowhere else: the
2// director reads this list for its commands and for the saved choice.
3//
4// See docs/companion/ANIMALS.md for what writing one involves.
5
6import type { Animal } from '../animal'
7import { goose } from './goose'
8import { hippo } from './hippo'
9import { penguin } from './penguin'
10import { shiba } from './shiba'
11
12// Gus first: the band shows the first of these to anyone who has not picked
13// one, and a saved choice wins over it.
14export const ANIMALS: readonly Animal[] = [goose as Animal, hippo as Animal, penguin as Animal, shiba as Animal]
15
16export const DEFAULT_ANIMAL = ANIMALS[0]!
17companion/feed.ts 206 lines1// What the companion reads from MemHub and from the mod, as plain data: the
2// fires the mod's act side writes to `$.state` (fires), the proposed rules
3// the poll writes there (proposals), and the REST answers
4// the poll and the Activate / Reject buttons get back. Pure: no `$`, so every
5// rule here is a unit test away (feed.test.ts).
6//
7// The REST shapes mirror scripts/rule_decide.py byte for byte (it stays, for
8// the skills): `GET /v1/team/rulebook/rules?status=eq.proposed&author=eq.xtrace
9// &order=created_at.desc`, kept when `source_ref` starts with `<session>#`, and
10// `PATCH /v1/team/rulebook/rules/<id>` with `{"status": "active"|"dismissed"}`.
11
12import { audienceOf, type Api } from '../mod/ctx'
13import type { FireNote, LaneName, ProposalNote } from '../types'
14import type { Decision, Proposed } from './proposed'
15
16/** One fire's identity: the same note read twice is one announcement. */
17export const fireKey = (f: FireNote) => `${f.at}|${f.ruleId}|${f.isBlocked ? 1 : 0}|${f.rule}`
18
19/** `$.state` fires as written, or [] for anything that is not a list of notes. */
20export function firesOf(value: unknown): FireNote[] {
21 if (!Array.isArray(value)) return []
22 return value.filter(
23 (f): f is FireNote =>
24 !!f && typeof f === 'object' && typeof f.rule === 'string' && f.rule.trim() !== '' &&
25 typeof f.isBlocked === 'boolean' && typeof f.at === 'number',
26 )
27}
28
29/**
30 * The notes in `fires` not in `seen`, oldest first, and the keys to remember
31 * next: every key the list holds now. The writer keeps the newest 50 and only
32 * appends, so a key that left the list never comes back and need not be kept.
33 */
34export function freshFires(fires: readonly FireNote[], seen: ReadonlySet<string>): { fresh: FireNote[]; seen: Set<string> } {
35 const keys = new Set<string>()
36 const fresh: FireNote[] = []
37 for (const f of fires) {
38 const key = fireKey(f)
39 if (!keys.has(key) && !seen.has(key)) fresh.push(f)
40 keys.add(key)
41 }
42 return { fresh, seen: keys }
43}
44
45/** `$.state` lanes as written, or [] for anything else. */
46export function lanesOf(value: unknown): LaneName[] {
47 return Array.isArray(value) ? value.filter((l): l is LaneName => typeof l === 'string') : []
48}
49
50/**
51 * Whether the classic hook for `lane` is the fallback that may announce: only
52 * while the mod has not claimed that lane. A claimed lane's fires come through
53 * `$.state` fires; reading the classic answer too would say each one twice.
54 */
55export const isClassicLane = (lanes: readonly LaneName[], lane: 'pre' | 'post') => !lanes.includes(lane)
56
57/** `$.state` proposals as written, or null when never written or not a list. */
58export function proposalsOf(value: unknown): ProposalNote[] | null {
59 if (!Array.isArray(value)) return null
60 return value.filter(
61 (p): p is ProposalNote =>
62 !!p && typeof p === 'object' && typeof p.ruleId === 'string' && p.ruleId !== '' &&
63 typeof p.title === 'string' && typeof p.at === 'number',
64 )
65}
66
67/** The ids in a list of proposals, in order: what changed between two reads. */
68export const proposalsKey = (notes: readonly ProposalNote[]) => notes.map(p => p.ruleId).join(',')
69
70/**
71 * The poll's answer, `GET …/rules?status=eq.proposed&author=eq.xtrace…`, as
72 * this session's waiting rules — rule_decide.py `proposed()`'s filter: a row
73 * whose `source_ref` starts with `<session>#`. null when the reply is not the
74 * REST envelope with a `data.rules` list: "could not ask" is not "nothing is
75 * waiting", and only a real list may take an ask off the band.
76 */
77export function listedRules(text: string, session: string, at: number): ProposalNote[] | null {
78 let payload: unknown
79 try {
80 payload = JSON.parse(text || '{}')
81 } catch {
82 return null
83 }
84 const data = payload && typeof payload === 'object' ? (payload as { data?: unknown }).data : undefined
85 const rules = data && typeof data === 'object' ? (data as { rules?: unknown }).rules : undefined
86 if (!Array.isArray(rules)) return null
87 if (!session) return []
88 return rules
89 .filter((r): r is Record<string, unknown> => !!r && typeof r === 'object' && !Array.isArray(r))
90 .filter(r => String(r.source_ref ?? '').startsWith(`${session}#`))
91 .map(r => {
92 const note: ProposalNote = { ruleId: String(r.rule_id ?? ''), title: String(r.title ?? '').trim(), at }
93 if (typeof r.rulebook_id === 'string' && r.rulebook_id) note.rulebookId = r.rulebook_id
94 const audience = audienceOf(r)
95 if (audience) note.audience = audience
96 return note
97 })
98 .filter(p => p.ruleId)
99}
100
101/**
102 * `$.state` proposals after a poll: the server's whole list, each note keeping
103 * the `at` it was first written with, plus any note written since the poll
104 * began (`since`) that the list does not name yet — a note another writer
105 * pushed while the poll was in flight, which the poll's list may predate.
106 * Everything else the list no longer names was decided, so it goes.
107 */
108export function mergedProposals(current: readonly ProposalNote[], listed: readonly ProposalNote[], since: number): ProposalNote[] {
109 const byId = new Map(current.map(p => [p.ruleId, p]))
110 const named = new Set(listed.map(p => p.ruleId))
111 const kept = listed.map(p => {
112 const was = byId.get(p.ruleId)
113 if (!was) return p
114 const rulebookId = p.rulebookId ?? was.rulebookId
115 const audience = p.audience ?? was.audience
116 // no `rulebookId: undefined`: $.state takes JSON data
117 return { ...p, at: was.at, ...(rulebookId ? { rulebookId } : {}), ...(audience ? { audience } : {}) }
118 })
119 const pushed = current.filter(p => !named.has(p.ruleId) && p.at >= since)
120 return [...kept, ...pushed]
121}
122
123/** A note as the band asks it: the env is the API's the companion reaches. */
124export const proposedOfNote = (p: ProposalNote, env: string): Proposed =>
125 ({ title: p.title, ruleId: p.ruleId, env, ...(p.audience ? { audience: p.audience } : {}) })
126
127/** The env as the band's words name it (proposed.ts decisionSaid): the mod's ctx.env, spelled as harness_stop does. */
128export const envName = (env: 'staging' | 'prod') => (env === 'prod' ? 'production' : 'staging')
129
130/**
131 * harness_stop.rule_url(): where the rule opens in MemHub Studio, or, with no
132 * id, the rulebook page itself (the demo's). `studio` is the web app api-info
133 * pairs with the API (plugin_onboarding._ORIGINS); '' when it pairs none — a
134 * link into another MemHub would open a rule that is not there.
135 */
136export function studioUrl(studio: string, ruleId: string): string {
137 const origin = studio.replace(/\/+$/, '')
138 if (!/^https:\/\/[^/?#\s]+$/.test(origin)) return ''
139 if (ruleId && !UUID.test(ruleId)) return ''
140 return ruleId ? `${origin}/studio/rulebook?open=${ruleId}` : `${origin}/studio/rulebook`
141}
142
143/** rule_decide.py's `_UUID`: an id the PATCH is sent for. */
144export const UUID = /^[0-9a-fA-F-]{36}$/
145
146/** rule_decide.py's `_STATUS`: the only two ways out of `proposed`. */
147export const STATUS_OF = { activate: 'active', reject: 'dismissed' } as const
148
149/**
150 * rule_decide.py `decide()`'s outcome, from the PATCH's status and body: 403
151 * forbidden, 404 gone, 400/409 decided, any other failure an error; a 200
152 * whose envelope `code` is not 0 is a failure the transport reported as
153 * success; otherwise the rule's new status.
154 */
155export function decisionOf(status: number, text: string, action: keyof typeof STATUS_OF): Decision {
156 let payload: unknown
157 try {
158 payload = JSON.parse(text || '{}')
159 } catch {
160 payload = undefined
161 }
162 const body = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : undefined
163 if (status < 200 || status >= 300) {
164 const msg = String(body?.msg ?? '').slice(0, 200)
165 const outcome = ({ 403: 'forbidden', 404: 'gone', 400: 'decided', 409: 'decided' } as Record<number, string>)[status] ?? 'error'
166 return { outcome, msg: msg || `HTTP ${status}` }
167 }
168 if (payload === undefined) return { outcome: 'error', msg: 'unreadable reply' }
169 if (body && body.code !== undefined && body.code !== null && body.code !== 0) {
170 return { outcome: 'error', msg: String(body.msg ?? JSON.stringify(body)).slice(0, 200) }
171 }
172 const data = body?.data
173 const now = data && typeof data === 'object' ? (data as { status?: unknown }).status : undefined
174 return { outcome: typeof now === 'string' && now ? now : STATUS_OF[action], msg: '' }
175}
176
177/** A usable credential from ctx.api(): a base and a bearer. */
178export const isApi = (a: unknown): a is Api =>
179 !!a && typeof a === 'object' && typeof (a as Api).base === 'string' && (a as Api).base !== '' &&
180 typeof (a as Api).bearer === 'string' && (a as Api).bearer !== ''
181
182/** The manifest's version, for `X-MemHub-Plugin-Version`; '' when unreadable. */
183export function versionOfManifest(text: unknown): string {
184 try {
185 const v = (JSON.parse(String(text)) as { version?: unknown }).version
186 return typeof v === 'string' ? v : ''
187 } catch {
188 return ''
189 }
190}
191
192/** The headers rule_decide.py sends: its key, plugin_version.request_headers(), and a JSON body's type. */
193export function headersOf(api: Api, version: string, hasBody: boolean): Record<string, string> {
194 return {
195 Authorization: `Bearer ${api.bearer}`,
196 ...(version ? { 'X-MemHub-Plugin-Version': version } : {}),
197 ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
198 }
199}
200
201/** rule_decide.py `proposed()`'s request. */
202export const PROPOSED_PATH = '/v1/team/rulebook/rules?status=eq.proposed&author=eq.xtrace&order=created_at.desc'
203
204/** rule_decide.py `decide()`'s request path. */
205export const rulePath = (ruleId: string) => `/v1/team/rulebook/rules/${ruleId}`
206