SLOPSHOPPER

XTrace MemHub

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…

newbandguardcommandstatusprompt
★ 2v0.124.0Apache-2.0updated 2026-10-09XTraceAI/agent-plugins/plugins/memhub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · memhub
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /goose ⎿ memhub: goose: following the session ♥ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ memhub: MemHub rules: fell back to the command hooks

Draws

Band
♥
README

MemHub

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.

Install

XTrace publishes this plugin from two sources. Install it from one of them:

  • The Claude plugin directory. Install 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.
  • The XTrace marketplace. Add it by hand:
  /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:

  1. Run /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".

Authentication

There are two credentials, and setting up one does not set up the other.

  • MCP tools (the 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.
  • Hooks (/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:

  1. the memhub_token option;
  2. $MEMHUB_TOKEN;
  3. the stored access key;
  4. the cached OAuth 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.

Skills

Each skill runs as /memhub:<name>, or when you ask for it in plain words.

SkillWhat it does, reads and sends
loginSigns the plugin in and stores its access key (see Authentication).
onboardSigns 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-artifactUploads a file you name as an artifact, to the repository's brain when there is one, else to your personal memory.
import-sessionReads 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-memoryRead-only search of the brain and your memory through the MCP tools.
handoff-sessionWrites 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-prLinks or unlinks a session and a pull request in MemHub. Asks the agent to run gh pr view.
find-contributing-sessionsReads 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-babysitPolls 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-ruleDrafts a team rule, replays it over this machine's sessions, and files it as proposed with create_rule.
start-rulebookFills 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-maintainSpec-driven work against Git specs or brain documents. A --cloud bootstrap runs on the MemHub backend.
companionChecks, shows or hides the companion (see Companion).

What it runs, sends, and fetches

Hooks

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)NameWhat it doesSends data?
PreToolUse (Bash, Edit, MultiEdit, Write, NotebookEdit, Read)rulebook_hook preChecks 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_gateDenies 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_originAdds 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_sessionAfter 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_reminderIf the edited file belongs to a spec in the repository (by spec frontmatter), reminds the agent once per session.No
PostToolUse (Bash)pr_babysit_triggerAfter 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_captureRecords 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 postRule 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_triggerWhen 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
SessionStartcapture_healthWarns you when capture is unauthenticated or recently failed. Checks plugin compatibility with MemHub and whether a newer release exists.Yes, see Network destinations
SessionStartbrain_brief briefGives 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
SessionStartrulebook_hook sessionLoads your team rules into the session. Fetches them first if the cache is stale.Yes, the repository name
SessionStartharness_stop sessionUnless MEMHUB_HARNESS_EXTRACT turns it off. Tells the agent the hand-off rule, unseen by you. See Harness-tied rule drafting.No
UserPromptSubmitbrain_brief promptDelivers brain pointers the session-start brief didn't have yet.No
UserPromptSubmitrulebook_hook promptFires rules written for prompts. These only advise.No. Fires are logged locally and uploaded at Stop
Stopflush_turnUploads the transcript bytes written since the last successful upload. Runs in the background.Yes, the transcript
Stopbrain_brief refreshRefreshes the cached brain overview, at most every 6 hours. Runs in the background.Yes, a brain id
Stopmd_capture_flushSaves qualifying Markdown files as draft artifacts. Runs in the background. See Markdown capture.Yes, file contents
Stoprulebook_hook flushUploads the rule-fire and rule-event logs. Once a day it also deletes stale local state. Runs in the background.Yes, identifiers
Stopharness_stop stopUnless MEMHUB_HARNESS_EXTRACT turns it off.Yes, when on
SessionEndsession_endRuns flush_session.py (re-sends the whole transcript as a backstop), then rulebook_hook flush final. Runs in the background.Yes, the transcript

Session capture

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.

Markdown capture

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:

  • it is a .md file of 6,000 to 2,000,000 bytes; or
  • it is a .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

rulebook_hook.py sends:

  • fetch: the repository name (the origin remote's basename, else the directory name).
  • fires and fire events: rule id, session, repo, branch, tool, a hashed checkout id, timestamps and the judge's verdict. Events record what followed a fire: a command that satisfied it, a turn or session end, or a rule you set aside. A fire on a call you overrode, and an event for a rule you set aside, also carry the reason you gave (redacted, up to 2,000 characters). The matched excerpt stays in the local log.
  • rules tied to files or commands (anchor rules): nothing. They are matched on this machine against the cached rules: a rule fires when one of its anchors appears in the command or the edited file's path as a whole identifier. Set MEMHUB_RULEBOOK_RECALL=0 to turn this matching off.
  • judge: when a rule fires on a call, it asks MemHub whether the rule fits the turn. The request carries your current message (up to 2,000 characters), a stripped copy of the turn (the agent's text, one line per tool call, the first 300 characters of each result), the call, and the fired rule ids. It goes through the same denylist redaction, which can miss things. Set 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 and PR linking

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).

Harness-tied rule drafting (on by default)

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 (Claude Code 2.1.287+)

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.

Codex and Cursor

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.

Network destinations

  • 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

Source 49 files
mod/register.ts 470 lines
1// 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}
470
companion/register.ts 1280 lines
1// 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 lines
1// 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}
291
mod/book.ts 483 lines
1// 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}
483
mod/claims.ts 324 lines
1// 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}
324
mod/ctx.ts 154 lines
1// 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\/?$/, '')
154
mod/engine/index.ts 149 lines
1// 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}
149
mod/engine/engine.ts 1324 lines
1// 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 lines
1// 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}
267
companion/animal.ts 143 lines
1// 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}
143
companion/animals/index.ts 17 lines
1// 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]!
17
companion/feed.ts 206 lines
1// 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