SLOPSHOPPER

loopkeeper Handoff Guard

Never lose a session to the context limit: it watches context fill, writes a handoff into .claude/brain/ in time, then clears and resumes from it.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lk-handoff-guard
│ ┃ handoff-guard ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ╭──────────────────────────────────────────╮ │ lk-handoff-guard │ │ ┃ │ handoff guard │ ⏺ Read(src/auth.ts) │ Handoff written: .claude/brain/project.md │ │ ┃ │ Context 49% · next handoff at 85% │ ⎿ Read 6 lines │ (facts only) │ │ ┃ │ 97k of 200k tokens · mode ask · last │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ │ handoff 2025-10-09 08:53 │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ┌──────────────────────────────────────────┐ │ ┃ │ Context │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 49% │ │ ┃ └──────────────────────────────────────────┘ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ━━━━━━━━━━━━━━━━━━━╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ 49% › /handoff │ ┃ ⎿ lk-handoff-guard: Handoff written to /work/app/.claude/brain/pro │ ┃ READINGS · 2 │ ┃ ● ok 08:53 start ━━━━━━━━━━╌╌╌╌╌╌╌╌╌╌ 4 │ ┃ ● ok 08:53 complete ━━━━━━━━━━╌╌╌╌╌╌╌╌╌╌ 4 │ ┃ │ ┃ LAST HANDOFF 2025-10-09 08:53 │ ┃ ● facts /work/app/.claude/brain/project.md │ ┃ Status today │ ┃ Last commits: a1b2c3d fix: refresh expired │ ┃ tokens (2 minutes ago); 9f8e7d6 feat: add │ ┃ audit log (3 hours ago). Edited: │ ┃ src/auth.ts, src/audit.ts, src/cache.ts. handoff ● ctx 49% last 2025-10-09 08:53 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
handoff ● ctx 49% last 2025-10-09 08:53 ⟨Claude Code's own drawing⟩
Pane · handoff-guard
╭──────────────────────────────────────────────────────────╮ │ handoff guard │ │ Context 49% · next handoff at 85% │ │ 97k of 200k tokens · mode ask · last handoff 2025-10-09 │ │ 08:53 │ ╰──────────────────────────────────────────────────────────╯ ┌──────────────────────────────────────────────────────────┐ │ Context │ │ 49% │ └──────────────────────────────────────────────────────────┘ ━━━━━━━━━━━━━━━━━━━╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ 49% READINGS · 2 ● ok 08:53 start ━━━━━━━━━━╌╌╌╌╌╌╌╌╌╌ 49% ● ok 08:53 complete ━━━━━━━━━━╌╌╌╌╌╌╌╌╌╌ 49% LAST HANDOFF 2025-10-09 08:53 ● facts /work/app/.claude/brain/project.md Status today Last commits: a1b2c3d fix: refresh expired tokens (2 minutes ago); 9f8e7d6 feat: add audit log (3 hours ago). Edited: src/auth.ts, src/audit.ts, src/cache.ts. Last request: "fix the failing auth test" (Written by handoff-guard 2025-10-09 08:53, context 49%, branch feat/auth-refresh, facts only.) Next • Re-read the last request below and check git status before continuing. • Continue: fix the failing auth test Log: (handoff-guard, facts only) Handed off at 49% context after editing src/auth.ts, src/audit.ts, src/cache.ts. Next: re-read the last request. Facts (from code) • Written: 2025-10-09 08:53 • Working directory: /work/app • Git branch: feat/auth-refresh • Context at handoff: 49% (97400 of 200000 tokens) Last commits • a1b2c3d fix: refresh expired tokens (2 minutes ago) • 9f8e7d6 feat: add audit log (3 hours ago) Files edited this turn • src/auth.ts • src/audit.ts • src/cache.ts Files edited earlier this session None. Unanswered questions to the user None. Last user request fix the failing auth test Last answer (excerpt) I updated src/auth.ts to reject expired claims and added an audit call. w: Write handoff c: Close
README

lk-handoff-guard

An add-on plugin of the loopkeeper set (its command stays /handoff). Never lose a session to the context limit. The plugin watches context fill and writes a session handoff into the project's handoff folder, .claude/brain/ (see The handoff folder), before the limit is reached. It then clears the session and continues from that handoff. What the session knew is written down as plain Markdown (status, next steps, dead ends, decisions, open questions) that you and the next session can read, instead of living only in the summary a compaction makes.

Use it when

  • Long sessions keep running into the context limit, or auto-compaction lands in the middle of a task.
  • You want a plain-file record in the repo of where each session left off and what was decided.
  • You pick work up in a later session, or hand it to someone else, and want it to start from a written brief.

Install: /plugin install lk-handoff-guard@loopkeeper, after /plugin marketplace add connectwithub/loopkeeper (more under Install). Requires Claude Code 2.1.294 or later.

Data handling: it keeps its settings, the last brief and a pending resume entry in its own plugin store, and its context readings in session state. It reads ~/.claude/handoff-guard.json and <project>/.claude/handoff-guard.json, and writes only the four handoff files in <project>/.claude/brain/. It runs read-only git rev-parse and git log. Each handoff makes one model call on your own Claude Code session, which spends tokens on your account; it sends nothing over the network of its own. It collects no personal data; the handoff files and saved settings stay until you delete them. Details under Footprint.

Commands: /handoff and its options are listed under Commands.

How it behaves as the context fills:

  • Below warnAt (70%): a quiet band, handoff ● ctx 41% in the ok colour.
  • At warnAt: the chip turns to the Claude orange (the kit's accent tone, not the theme's warning colour), and one toast says "context 72%: run /handoff when you reach a stopping point".
  • At handoffAt (85%), the chip turns red, the band's right side ("handoff due: /handoff clear") takes the default text colour instead of the quiet one, and the mode decides what happens:
  • ask (default): the mod asks "Context at 86%. Write the handoff and clear now?" with three options: Handoff now, Later (ask at 92%) and Off this session.
  • auto: it writes the handoff and clears without asking.
  • off: only the red band.
  • Before compaction (session.compact, auto or manual): if no handoff has been written since the last clear or compaction, the mod writes one first. Compaction then goes ahead, because the mod never blocks it.
  • Tool calls are never refused, with one exception: in auto mode past denyAt (95%), new tool calls get a reason telling Claude that the handoff is being written.

Context fill comes from $.session.usage() at three points:

  • on session.start;
  • after every main-loop turn.complete;
  • before each main-loop request, in a turn.step generator hook.

The turn.step check also adds an estimate of tool output that has landed since the last response. That way a long, tool-heavy turn is caught mid-turn: the turn ends there, and the handoff runs. Subagent events (e.agentId) are ignored for measurement.

Install

Needs Claude Code 2.1.294 or later. From the loopkeeper marketplace, inside a Claude Code session:

/plugin marketplace add connectwithub/loopkeeper
/plugin install lk-handoff-guard@loopkeeper

Turn it off or on again with /plugin disable lk-handoff-guard@loopkeeper and /plugin enable lk-handoff-guard@loopkeeper. To try it from a clone without installing it: claude --plugin-dir /path/to/loopkeeper/plugins/lk-handoff-guard. Check and test it from its folder with claude plugin validate --strict . and claude plugin test ..

Commands

| Command | What it does | | :- | :- | | /handoff | Write the handoff now, without clearing | | /handoff clear | Write the handoff, then /clear and resume from it | | /handoff status | Context %, levels, mode, and the last handoff's time and path | | /handoff show | Pane with the handoff state, the last 10 readings, the last brief, and a Write button (see Pane) | | /handoff mode ask\|auto\|off | Set the mode | | /handoff at <0-1> | Handoff level (0.85, 85 and 85% all work) | | /handoff warn <0-1> | Warning level | | /handoff band on\|off | Show or hide the band. The band stacks other mods' band content under its own line. |

Pane

/handoff show opens a pane drawn with the kit (hooks/kit.ts, a verbatim copy of the loopkeeper core's kit v3): bordered, unfilled cards on every surface, with ● text badges in the terminal and fully rounded Svg pills in the Desktop app. At the top, a hero card (kicker HANDOFF GUARD) gives the handoff state in the band's words, such as "Context 72% · /handoff at a stopping point", "handoff due: /handoff clear" or "next handoff at 85%", with the tokens, the mode and the last handoff's time under it. Levels use theme colours: ok in green, warn in the accent orange, due in red (the hero, the Context tile, the bars and the level badges); the warn pill is orange in the Desktop app too. Tiles show Context, Warn at (orange), Handoff at (red) and Mode, and a bar shows the context fill. The READINGS · n section lists the last 10 readings, each with a level badge (ok, warn, due), its time, where it was taken and its own bar. The LAST HANDOFF section shows the brief's source as a badge (fork and complete in green, facts in orange), its path, any unverified figures (an orange badge), and the brief itself. The Write handoff (w) and Close (c) buttons sit at the bottom. The band above the prompt is one kit line: the bold label, the context chip in the level's colour, and the right side in the quiet colour, or in the default text colour once the handoff is due.

Config

The layers are merged in this order, and later layers win:

  1. the defaults;
  2. ~/.claude/handoff-guard.json;
  3. <cwd>/.claude/handoff-guard.json;
  4. what the /handoff commands saved in $.store.
{ "warnAt": 0.70, "handoffAt": 0.85, "reaskAt": 0.92, "denyAt": 0.95,
  "mode": "ask", "model": "haiku", "maxBriefTokens": 2000, "band": true }

Levels are kept in order (warn ≤ handoff ≤ reask ≤ deny), and bad values are dropped.

The handoff

  1. Facts, collected in code. These are:
  2. the working directory;
  3. the git branch and the last 5 commits ($.process.run git);
  4. files edited this turn and this session (from observing Edit, Write, MultiEdit and NotebookEdit calls, plus the transcript);
  5. the last real user prompt and an excerpt of Claude's last answer ($.session.messages());
  6. open questions, meaning AskUserQuestion calls with no answer;
  7. the context figures;
  8. the loopkeeper core's current stage, when the core is on.

Credential-shaped strings are redacted.

  1. Judgment sections from a cheap model. The mod tries $.model.fork first, because it reuses the session's prompt cache. If that fails, it falls back to $.model.complete with model and maxBriefTokens, and a compact transcript. The template has six headings: Status, Next steps, Dead ends, Open questions, Decisions (pointers only), Log (one line). A reply is accepted only if it has all six headings. Otherwise only the facts are written. After that, markUnverifiedFigures appends (unverified) to any number in the model text that does not appear in the facts. It skips counts under 10, dates, times, versions and inline code.
  2. Written to the handoff folder. The plugin writes the files directly through $.fs, so it needs no script, python or other tool, and anything else that reads the folder (a resume hook at session start, a sync at session end) keeps working unchanged.
  3. project.md: rewrites only ## Status today, ## Next and ## Dead ends. Dead ends is left alone when the model found none. The other sections are kept, the frontmatter updated: field is set to today, and a stamp records the time, context % and branch. If the file is missing, it is created from the template described under The handoff folder.
  4. log.md: one - YYYY-MM-DD — text line per session, under 300 characters. A second handoff in the same session replaces the session's own line while it is still the last line.
  5. decisions.md and questions.md: dated lines are appended, and exact duplicates are skipped.
  6. A toast with the path.
  7. Clear and resume (ask and auto flows, and /handoff clear). A pending entry goes into $.store, and then $.command.run({command:'clear'}) runs from a short $.clock.after timer. The timer is needed because a command.run hook may not run a command, and a turn.step hook holds the turn. On the next classic.SessionStart with source=clear, the mod deletes the entry and submits one line: "Resume from the handoff in .claude/brain/project.md (written <time>); read it first."

Why $.store, not module variables (claude-auto-handoff's choice). A hot reload of the module between the handoff and the clear would lose a module variable. $.store survives both a reload and the clear, and it is keyed by working directory. $.state holds only per-session state (readings, warned or asked flags, edited files). /clear resets it, which is the reset the mod wants. The entry is deleted before the prompt is submitted, so a session is never seeded twice, and entries older than 15 minutes are ignored.

The handoff folder

The handoff lives in the project's .claude/brain/ folder as four Markdown files, written for people and for any tool that wants to pick a session up again:

  • project.md: YAML frontmatter (type, name, summary, tags, created, updated and a few status fields), then the sections ## What it is, ## Why, ## Status today, ## Next and ## Dead ends. The plugin rewrites only the last three and updated:; the first two are yours. A new file starts from a template whose own sections say "Not filled in yet".
  • log.md: a # Log header, then one line per session, newest last: - YYYY-MM-DD — what changed, what is next, what is blocked, under 300 characters.
  • decisions.md: a # Decisions header, then - YYYY-MM-DD — decision and why lines. Append only; to reverse a decision, add a new dated line.
  • questions.md: a # Questions header, then - YYYY-MM-DD — question lines for questions only a person can answer. Remove a line once it is answered and record the answer as a decision.

After /clear, the resume prompt points at project.md.

Footprint

What claude plugin validate --strict . reports for this plugin (Claude Code 2.1.294), and what that means:

AreaWhat the plugin has
Hooks moduleOne: ./register.ts, in hooks/hooks.json.
Hook eventssession.start, classic.SessionStart, prompt.submit, turn.start, turn.step, turn.complete, tool.call, session.compact, command.run (handoff), ui.render (the band; the handoff-guard pane).
Refusalstool.call refuses new tool calls only in auto mode past denyAt (95%); turn.step ends a turn to hand off. Compaction is never blocked.
Calls ($.…)clock (after, now); command (register, run); env.get; fs (exists, read, write); model (complete, fork); process.run; prompt.submit; session (cwd, id, messages, usage); state (get, set); store (delete, get, set); ui (ask, close, open, resolve, toast).
State16 atoms under lk-handoff-guard; it also reads the loopkeeper core's stageCurrent when the core is on.
StoreThe /handoff settings, the last brief and the pending resume entry, keyed by working directory.
EnvReads HOME; writes nothing.

What it runs:

  • Processes: git rev-parse --abbrev-ref HEAD and git log -5 --pretty=format:%h %s, read-only.
  • Files: reads ~/.claude/handoff-guard.json and <project>/.claude/handoff-guard.json; reads and writes project.md, log.md, decisions.md and questions.md in <project>/.claude/brain/. Nothing else.
  • Model calls: one per handoff: $.model.fork (which reuses the session's prompt cache), else $.model.complete with model (haiku) and maxBriefTokens. Both spend tokens on your account. If both fail, only the facts are written.
  • Prompts submitted: after a handoff that cleared, one resume line ($.prompt.submit) in the fresh session.
  • Commands run: /clear, after a handoff in the ask and auto flows and on /handoff clear.
  • Network: none of its own.

Notes for directory reviewers

The directory's validator listed these items for this plugin. Each bullet says what the code does and where (hooks/register.ts unless named).

  • Programs it runs (policy holds MOD_RUNS_PROCESS, MOD_PROCESS_COMMAND_COMPUTED). Only git, from one helper, git() (line 279): $.process.run(['git', ...argv], { cwd, timeoutMs: 5000 }), in the session's directory. Its two calls are in collectFacts() (lines 306–307): git rev-parse --abbrev-ref HEAD and git log -5 --pretty=format:%h %s. Both read only; they put the branch and the last five commit subjects in the handoff's facts.
  • Local data and the conversation (policy holds MOD_LOCAL_DATA_LEAVES, MOD_SESSION_DATA_LEAVES). The arguments to git are fixed, so nothing read goes into a command. The conversation ($.session.messages(), line 298) is read when a handoff is written, to draft the note: first with $.model.fork (line 340), a fork of the session itself with a prompt that holds the facts block, else with $.model.complete (line 347) on the configured model (default haiku) with the most recent part of the transcript. Both go through Claude Code to the model service the session already uses; there is no other destination. The text the plugin composes for these prompts passes through redact() in hooks/logic.ts, which replaces credential-shaped strings with [redacted].
  • Files it writes (policy hold MOD_WRITES_BUILD_FILE, note MOD_WRITES_FILES). All writes are in writeBrain() (lines 372, 380, 395) and go to <project>/.claude/brain/ only: project.md (its Status today, Next and Dead ends sections and the updated date), log.md (one dated line per session) and decisions.md / questions.md (dated lines appended). Each path is join(cwd, '.claude/brain', name) with the name written in the code. These are the user's own project notes in Markdown, not build, start-up or settings files; no tool runs them.
  • The slash command it runs (policy hold MOD_RUNS_COMMAND). runClear() (line 457) runs $.command.run({ command: 'clear' }), that is /clear, and no other command. It runs only after a handoff was written: when the user answers Handoff now to the question at handoffAt (ask mode, the default), when the context passes handoffAt in auto mode, or on /handoff clear. If /clear is refused, a toast gives the handoff's path.
  • The resume prompt (policy hold MOD_DATA_LEAVES_BY_PROMPT). After a handoff that cleared, consumePending() (line 483) submits one prompt in the fresh session: Resume from the handoff in .claude/brain/project.md (written <YYYY-MM-DD HH:MM>); read it first. The only variable part is the time the handoff was written; it carries no file content. The queued entry is deleted before the submit, so it goes once, and only within 15 minutes of the handoff.
  • The classic.SessionStart hook (policy hold MOD_ANSWERS_PERMISSION, note MOD_HOOKS_POLICY_EVENT). The hook (line 613) calls next(e) first and returns its result unchanged: it never sets or alters the session's first message, settings, instructions or permissions. After clear, resume or compact it reloads the config and measures context fill; after clear it submits the queued resume prompt above.
  • "Uses a credential from the user's machine" (policy hold MCP_FORWARDS_CREDENTIAL_ENV). No credential is read or sent. The "key" the check found in hooks/logic.ts is redact() (line 244): regular expressions that match names such as API_KEY=… and token shapes such as sk-… or ghp_…, in order to replace them with [redacted]. http://www.w3.org/2000/svg in hooks/kit.ts is the SVG namespace inside the markup the UI kit draws, not a request. The plugin reads HOME only to find its config file.
  • Fields in plugin.json (warnings UNKNOWN_KEY). documentationUrl and supportUrl are listing fields the directory reads. types points at types/index.d.ts, the $.state contract that claude plugin validate checks. Claude Code ignores all three at load time.
  • Icon (warning ICON_MISSING). Added at .claude-plugin/icon.png (512 × 512); its source is docs/assets/icons/lk-handoff-guard.svg in the repo.
  • Calls named in text (note MOD_CALL_NAMED_IN_TEXT). tool.call and session.compact are named in the header comment (lines 11–12), which lists the hooks. The tool.call hook (line 691) refuses a call only in auto mode, in the main loop, once context fill passes denyAt (default 95%), with a message that the handoff is being written; AskUserQuestion, the plugin's own tools and calls that carry a consent value are never refused. Otherwise it returns what next(e) gave back, unchanged, after noting edited file paths for the handoff.
  • Its command (note MOD_REGISTERS_SURFACES). It registers one slash command, /handoff, at session start (line 600). It registers no tools or agents.
  • Hooks on events that are also calls (note MOD_HOOKS_OPERATION). prompt.submit (line 633) passes every prompt on unchanged with next(e); it only clears the band's "resumed" marker once the user types a prompt. session.compact (line 727) writes the handoff first when none was written since the last clear, then lets compaction run; it never blocks it. command.run (line 750) has the matcher { command: 'handoff' }, so it runs only for /handoff. None of them makes the call it hooks.

Credits

  • alexknowshtml/claude-auto-handoff (Alex Hillman):
  • context checks on turn.complete and turn.step, with the "unmeasured" tool-output estimate;
  • ending a turn from turn.step by yielding a stop chunk;
  • extracting facts in code and leaving judgment to the model, with a facts-only fallback;
  • the heading check on the model's reply;
  • markUnverifiedFigures, widened from token figures to any number;
  • the meta-prompt prefixes;
  • clearing with command.run and seeding with prompt.submit on classic.SessionStart.

Unlike auto-handoff, this mod lets compaction proceed and refuses tool calls only in auto mode past 95%.

  • hamzafer/claude-code-mods, session-saver (Hamza Zafar): showing a parked note on resume (here, "resumed from handoff <time>" in the band until you type), the typed-prompt test, and the resume-before-load race guard. On load, a fresh pending entry plus an empty transcript also seeds.

Limits

  • $.ui.ask from inside a turn.step hook (asking mid-turn) works in the test kit, but it has not been tried in a live session.
  • The plugin does not limit the length of project.md: its body can grow past 300 words if the other sections are long.
  • Decision and question lines come from the model: review them at the next session.
  • $.model.fork answers nothing-to-fork before the first response, and complete is used instead. If both fail, only the facts are written.
  • The tool-output estimate is about 4 characters per token, so a measurement taken mid-turn is approximate.
Source 4 files
hooks/register.ts 866 lines
1// handoff-guard: never lose a session to the context limit.
2//
3// session.start        loads config, reads context fill, registers /handoff; seeds a session
4//                      that missed the clear signal (resume-before-load race, from session-saver)
5// classic.SessionStart after /clear: reloads config ($.state was reset) and submits the one-line
6//                      resume prompt queued in $.store by the handoff that cleared
7// turn.step            before each main-loop request: measures fill (plus tool output not yet
8//                      measured) so a long tool-heavy turn is caught mid-turn; past handoffAt it
9//                      asks (ask) or hands off (auto) and ends the turn
10// turn.complete        after each main-loop turn: measures, warns once at warnAt, asks or hands off
11// tool.call            records edited files; in auto mode past denyAt refuses new calls
12// session.compact      writes the handoff first when none was written since the last clear, then
13//                      lets compaction proceed (never blocks it)
14// command.run          /handoff and its subcommands
15// ui.render            the one-line band (stacks other mods' band under it) and the pane
16//
17// State that must survive /clear (the queued resume prompt, the last brief, settings) lives in
18// $.store; $.state is reset by /clear, which is exactly the per-session state we want reset.
19
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, On } from 'claude-code'
22
23import type { BriefInfo, Config, Reading } from '../types'
24import {
25  DEFAULTS,
26  EDIT_TOOLS,
27  FILE_HEADERS,
28  HELP,
29  MODES,
30  appendLine,
31  bandParts,
32  briefMarkdown,
33  buildSections,
34  clip,
35  completePrompt,
36  datedLine,
37  dateOf,
38  extractFromMessages,
39  factsBlock,
40  forkPrompt,
41  isMeta,
42  levelOf,
43  markUnverifiedFigures,
44  mergeConfig,
45  parseBrief,
46  parseFraction,
47  pct,
48  projectTemplate,
49  readingOf,
50  renderTranscript,
51  replaceSection,
52  sanitize,
53  setFrontmatter,
54  slugOf,
55  upsertLogLine,
56  whenOf,
57  wordsFor,
58} from './logic'
59import { LEVEL_TONE } from './logic'
60import type { Facts, Msg, Parsed, Sections } from './logic'
61import { TONES, makeKit, tokensShort } from './kit'
62import type { Elements, Tone } from './kit'
63
64type Api = EngineInterface
65
66const PANE = 'handoff-guard'
67const SETTINGS_KEY = 'settings'
68const CONFIG_FILE = '.claude/handoff-guard.json'
69const BRAIN_DIR = '.claude/brain'
70const PENDING_TTL_MS = 15 * 60_000
71const RACE_TTL_MS = 2 * 60_000
72const CHARS_PER_TOKEN = 4
73const CLEAR_DELAY_MS = 250
74const ASK_NOW = 'Handoff now'
75const ASK_OFF = 'Off this session'
76
77const configAtom = atom({ plugin: 'lk-handoff-guard', key: 'config' } as const, DEFAULTS)
78const readingAtom = atom({ plugin: 'lk-handoff-guard', key: 'reading' } as const, null as Reading | null)
79const historyAtom = atom({ plugin: 'lk-handoff-guard', key: 'history' } as const, [] as Reading[])
80const warnedAtom = atom({ plugin: 'lk-handoff-guard', key: 'warned' } as const, false)
81const nextAskAtom = atom({ plugin: 'lk-handoff-guard', key: 'nextAskAt' } as const, 0)
82const askedOutAtom = atom({ plugin: 'lk-handoff-guard', key: 'askedOut' } as const, false)
83const sessionOffAtom = atom({ plugin: 'lk-handoff-guard', key: 'sessionOff' } as const, false)
84const autoTriedAtom = atom({ plugin: 'lk-handoff-guard', key: 'autoTried' } as const, false)
85const writtenAtom = atom({ plugin: 'lk-handoff-guard', key: 'written' } as const, false)
86const clearingAtom = atom({ plugin: 'lk-handoff-guard', key: 'clearing' } as const, false)
87const busyAtom = atom({ plugin: 'lk-handoff-guard', key: 'busy' } as const, false)
88const editedSessionAtom = atom({ plugin: 'lk-handoff-guard', key: 'editedSession' } as const, [] as string[])
89const editedTurnAtom = atom({ plugin: 'lk-handoff-guard', key: 'editedTurn' } as const, [] as string[])
90const lastBriefAtom = atom({ plugin: 'lk-handoff-guard', key: 'lastBrief' } as const, null as BriefInfo | null)
91const resumedAtom = atom({ plugin: 'lk-handoff-guard', key: 'resumed' } as const, '')
92const cwdAtom = atom({ plugin: 'lk-handoff-guard', key: 'cwd' } as const, '')
93
94// Module variables: a guard against two handoffs at once, and tool output that has landed
95// since the last measured response (borrowed from auto-handoff's "unmeasured").
96let inFlight = false
97let unmeasured = 0
98
99type Pending = { at: number; when: string; path: string; from: string }
100
101// ---------------------------------------------------------------- small helpers
102
103const capPreview = (text: string) => (text.length > 6000 ? text.slice(0, 6000) + '\n…' : text)
104
105const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
106
107function toast($: Api, text: string): void {
108  try {
109    $.ui.toast(text)
110  } catch {
111    // Display only.
112  }
113}
114
115async function now($: Api): Promise<number> {
116  try {
117    const t = await $.clock.now()
118    return typeof t === 'number' && Number.isFinite(t) ? t : Date.now()
119  } catch {
120    return Date.now()
121  }
122}
123
124async function cwdOf($: Api): Promise<string> {
125  const held = await read($, cwdAtom)
126  if (held) return held
127  try {
128    const c = await $.session.cwd()
129    if (typeof c === 'string' && c) {
130      await update($, cwdAtom, () => c)
131      return c
132    }
133  } catch {
134    // Fall through.
135  }
136  return ''
137}
138
139async function sessionId($: Api): Promise<string> {
140  try {
141    return String((await $.session.id()) ?? '')
142  } catch {
143    return ''
144  }
145}
146
147const join = (dir: string, rel: string) => (dir ? dir.replace(/\/+$/, '') + '/' + rel : rel)
148
149async function readText($: Api, path: string): Promise<string | null> {
150  try {
151    if (!(await $.fs.exists(path))) return null
152    const t = await $.fs.read(path)
153    return typeof t === 'string' ? t : null
154  } catch {
155    return null
156  }
157}
158
159async function readJson($: Api, path: string): Promise<unknown> {
160  const t = await readText($, path)
161  if (t === null) return null
162  try {
163    return JSON.parse(t)
164  } catch {
165    return null
166  }
167}
168
169async function storeGet($: Api, key: string): Promise<unknown> {
170  try {
171    return await $.store.get(key)
172  } catch {
173    return undefined
174  }
175}
176
177// ---------------------------------------------------------------- config
178
179async function loadConfig($: Api): Promise<Config> {
180  let home = ''
181  try {
182    home = (await $.env.get('HOME')) ?? ''
183  } catch {
184    home = ''
185  }
186  const cwd = await cwdOf($)
187  const user = home ? await readJson($, join(home, CONFIG_FILE)) : null
188  const project = cwd ? await readJson($, join(cwd, CONFIG_FILE)) : null
189  const saved = await storeGet($, SETTINGS_KEY)
190  const cfg = mergeConfig(user, project, saved)
191  await update($, configAtom, () => cfg)
192  return cfg
193}
194
195async function saveSetting($: Api, patch: Partial<Config>): Promise<Config> {
196  const saved = sanitize(await storeGet($, SETTINGS_KEY))
197  await $.store.set(SETTINGS_KEY, { ...saved, ...patch })
198  return loadConfig($)
199}
200
201/** The last brief for this project, from $.store, so /handoff status and the pane survive /clear. */
202async function loadLast($: Api): Promise<void> {
203  const cwd = await cwdOf($)
204  const v = (await storeGet($, 'last:' + cwd)) as BriefInfo | undefined
205  if (v && typeof v === 'object' && typeof v.path === 'string' && typeof v.when === 'string') {
206    await update($, lastBriefAtom, () => v)
207  }
208}
209
210// ---------------------------------------------------------------- measurement
211
212async function measure($: Api, where: string, extraTokens = 0): Promise<Reading | null> {
213  try {
214    const u = await $.session.usage()
215    const r = readingOf(u && (u as { context?: unknown }).context, await now($), where, extraTokens)
216    if (!r) return null
217    await update($, readingAtom, () => r)
218    await update($, historyAtom, (h: Reading[]) => [...h, r].slice(-30))
219    return r
220  } catch {
221    return null
222  }
223}
224
225/** Warns once at warnAt; past handoffAt asks, hands off or does nothing by mode. 'stop' ends the turn. */
226async function evaluate($: Api, r: Reading, where: 'start' | 'step' | 'complete'): Promise<'stop' | 'none'> {
227  const cfg = await read($, configAtom)
228  const level = levelOf(r.frac, cfg)
229  if (level !== 'ok' && !(await read($, warnedAtom))) {
230    await update($, warnedAtom, () => true)
231    toast($, `context ${pct(r.frac)}%: run /handoff when you reach a stopping point`)
232  }
233  if (where === 'start' || level !== 'due') return 'none'
234  if (cfg.mode === 'off' || (await read($, sessionOffAtom))) return 'none'
235  if (inFlight || (await read($, clearingAtom))) return 'none'
236
237  if (cfg.mode === 'auto') {
238    if (await read($, autoTriedAtom)) return 'none'
239    await update($, autoTriedAtom, () => true)
240    const info = await doHandoff($, true)
241    return info ? 'stop' : 'none'
242  }
243
244  // ask
245  if (await read($, askedOutAtom)) return 'none'
246  const nextAsk = await read($, nextAskAtom)
247  const threshold = nextAsk > 0 ? nextAsk : cfg.handoffAt
248  if (r.frac < threshold) return 'none'
249  const first = threshold < cfg.reaskAt
250  const later = first ? `Later (ask at ${pct(cfg.reaskAt)}%)` : 'Later'
251  let answer = later
252  try {
253    answer = await $.ui.ask(`Context at ${pct(r.frac)}%. Write the handoff and clear now?`, {
254      options: [ASK_NOW, later, ASK_OFF],
255      header: 'Handoff',
256    })
257  } catch {
258    // Dismissed, or nobody to ask (-p): treat as Later.
259    answer = later
260  }
261  if (answer === ASK_NOW) {
262    const info = await doHandoff($, true)
263    return info ? 'stop' : 'none'
264  }
265  if (answer === ASK_OFF) {
266    await update($, sessionOffAtom, () => true)
267    toast($, 'handoff-guard: off for this session. /handoff still writes one.')
268    return 'none'
269  }
270  if (first) await update($, nextAskAtom, () => cfg.reaskAt)
271  else await update($, askedOutAtom, () => true)
272  return 'none'
273}
274
275// ---------------------------------------------------------------- the handoff
276
277async function git($: Api, cwd: string, argv: string[]): Promise<string> {
278  try {
279    const r = await $.process.run(['git', ...argv], { cwd: cwd || undefined, timeoutMs: 5000 })
280    return r && r.exitCode === 0 && typeof r.stdout === 'string' ? r.stdout.trim() : ''
281  } catch {
282    return ''
283  }
284}
285
286async function loopkeeperStage($: Api): Promise<string> {
287  try {
288    // The loopkeeper core's stage, not in this contract: typed loosely and read only when present.
289    const got = (await $.state.get({ plugin: 'loopkeeper', key: 'stageCurrent' } as never)) as { value?: unknown } | undefined
290    return got && typeof got.value === 'string' ? got.value : ''
291  } catch {
292    return ''
293  }
294}
295
296async function messagesOf($: Api): Promise<Msg[]> {
297  try {
298    const m = await $.session.messages()
299    return Array.isArray(m) ? (m as unknown as Msg[]) : []
300  } catch {
301    return []
302  }
303}
304
305async function collectFacts($: Api, messages: Msg[]): Promise<Facts> {
306  const cwd = await cwdOf($)
307  const branch = await git($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
308  const log = await git($, cwd, ['log', '-5', '--pretty=format:%h %s'])
309  const x = extractFromMessages(messages)
310  const seen = await read($, editedSessionAtom)
311  const editedSession = [...new Set([...x.edited, ...seen])]
312  const r = await read($, readingAtom)
313  return {
314    cwd,
315    when: whenOf(await now($)),
316    branch,
317    commits: log ? log.split('\n').map(l => clip(l, 120)).filter(Boolean).slice(0, 5) : [],
318    editedTurn: await read($, editedTurnAtom),
319    editedSession,
320    lastPrompt: x.lastPrompt,
321    lastAnswer: x.lastAnswer,
322    openQuestions: x.openQuestions,
323    stage: await loopkeeperStage($),
324    ctxPercent: r ? pct(r.frac) : null,
325    ctxTokens: r ? r.tokens : null,
326    ctxWindow: r ? r.window : null,
327  }
328}
329
330/** Fork first (shares the session's prompt cache), then complete; null when neither gives all headings. */
331async function draft($: Api, cfg: Config, facts: string, messages: Msg[]): Promise<{ parsed: Parsed | null; source: string; flagged: string[] }> {
332  const words = wordsFor(cfg.maxBriefTokens)
333  const tryText = (text: unknown, source: string) => {
334    if (typeof text !== 'string' || !parseBrief(text)) return null
335    const checked = markUnverifiedFigures(text, facts)
336    const parsed = parseBrief(checked.text)
337    return parsed ? { parsed, source, flagged: checked.flagged } : null
338  }
339  try {
340    const r = await $.model.fork({ prompt: forkPrompt(facts, words) })
341    const got = r && r.isAnswered ? tryText(r.text, 'fork') : null
342    if (got) return got
343  } catch {
344    // Fall back to complete.
345  }
346  try {
347    const r = await $.model.complete({
348      model: cfg.model,
349      system: 'You write short, precise handoff notes for coding sessions.',
350      prompt: completePrompt(facts, renderTranscript(messages), words),
351      maxTokens: cfg.maxBriefTokens,
352      timeoutMs: 60_000,
353    })
354    const got = r && r.isAnswered ? tryText(r.text, 'complete') : null
355    if (got) return got
356  } catch {
357    // Facts only.
358  }
359  return { parsed: null, source: 'facts', flagged: [] }
360}
361
362/** Writes project.md sections, a log line, decisions and questions in the handoff folder's shapes. */
363async function writeBrain($: Api, cwd: string, s: Sections, at: number, sid: string): Promise<string> {
364  const dir = join(cwd, BRAIN_DIR)
365  const date = dateOf(at)
366  const projectPath = join(dir, 'project.md')
367  let project = (await readText($, projectPath)) ?? projectTemplate(slugOf(cwd), date)
368  project = replaceSection(project, 'Status today', s.status)
369  project = replaceSection(project, 'Next', s.next)
370  if (s.deadEnds) project = replaceSection(project, 'Dead ends', s.deadEnds)
371  project = setFrontmatter(project, 'updated', date)
372  await $.fs.write(projectPath, project)
373
374  const logPath = join(dir, 'log.md')
375  const logLine = datedLine(date, s.log)
376  const prevKey = 'logline:' + cwd
377  const prev = (await storeGet($, prevKey)) as { sid?: string; line?: string } | undefined
378  const previous = prev && prev.sid === sid && typeof prev.line === 'string' ? prev.line : undefined
379  const logDoc = (await readText($, logPath)) ?? FILE_HEADERS['log.md']
380  await $.fs.write(logPath, upsertLogLine(logDoc, logLine, previous))
381  try {
382    await $.store.set(prevKey, { sid, line: logLine })
383  } catch {
384    // The next handoff appends instead of replacing.
385  }
386
387  for (const [file, items] of [
388    ['decisions.md', s.decisions],
389    ['questions.md', s.questions],
390  ] as const) {
391    if (!items.length) continue
392    const path = join(dir, file)
393    let doc = (await readText($, path)) ?? FILE_HEADERS[file]
394    for (const item of items) doc = appendLine(doc, datedLine(date, item)).doc
395    await $.fs.write(path, doc)
396  }
397  return projectPath
398}
399
400/** The whole handoff. With clear, queues the resume prompt in $.store and then /clear. */
401async function doHandoff($: Api, clear: boolean): Promise<BriefInfo | null> {
402  if (inFlight) return null
403  inFlight = true
404  await update($, busyAtom, () => true)
405  try {
406    const cfg = await read($, configAtom)
407    const cwd = await cwdOf($)
408    const sid = await sessionId($)
409    const messages = await messagesOf($)
410    const facts = await collectFacts($, messages)
411    const factsText = factsBlock(facts)
412    const { parsed, source, flagged } = await draft($, cfg, factsText, messages)
413    const sections = buildSections(parsed, facts)
414    const at = await now($)
415    const path = await writeBrain($, cwd, sections, at, sid)
416    const info: BriefInfo = {
417      at,
418      when: whenOf(at),
419      path,
420      sessionId: sid,
421      source,
422      flagged,
423      preview: capPreview(briefMarkdown(sections, factsText)),
424    }
425    await update($, lastBriefAtom, () => info)
426    await update($, writtenAtom, () => true)
427    try {
428      await $.store.set('last:' + cwd, info)
429    } catch {
430      // Status after /clear falls back to nothing.
431    }
432    const shown = path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
433    toast($, `Handoff written: ${shown}${source === 'facts' ? ' (facts only)' : ''}${flagged.length ? ` · ${flagged.length} unverified figure(s)` : ''}`)
434    if (clear) {
435      const pending: Pending = { at, when: info.when, path, from: sid }
436      await $.store.set('pending:' + cwd, pending)
437      await update($, clearingAtom, () => true)
438      // From a timer, outside any event: a command.run hook may not run a command, and a
439      // turn.step hook holds the turn. Not awaited: /clear runs once the session is idle.
440      $.clock.after(CLEAR_DELAY_MS, () => runClear($, cwd, shown))
441    }
442    return info
443  } catch (err) {
444    toast($, 'handoff-guard: the handoff failed: ' + errText(err))
445    return null
446  } finally {
447    inFlight = false
448    try {
449      await update($, busyAtom, () => false)
450    } catch {
451      // Display only.
452    }
453  }
454}
455
456function runClear($: Api, cwd: string, shown: string): void {
457  $.command.run({ command: 'clear' }).catch(async (err: unknown) => {
458    toast($, `handoff-guard: /clear was refused (${errText(err)}); the handoff is at ${shown}`)
459    try {
460      await $.store.delete('pending:' + cwd)
461      await update($, clearingAtom, () => false)
462    } catch {
463      // Nothing else to undo.
464    }
465  })
466}
467
468/** After /clear: submit the one-line resume prompt the handoff queued, once. */
469async function consumePending($: Api, maxAgeMs: number): Promise<boolean> {
470  const cwd = await cwdOf($)
471  const key = 'pending:' + cwd
472  const p = (await storeGet($, key)) as Pending | undefined
473  if (!p || typeof p !== 'object' || typeof p.when !== 'string' || typeof p.at !== 'number') return false
474  try {
475    await $.store.delete(key)
476  } catch {
477    return false // Never risk submitting twice.
478  }
479  if ((await now($)) - p.at > maxAgeMs) return false
480  await update($, resumedAtom, () => p.when)
481  const text = `Resume from the handoff in .claude/brain/project.md (written ${p.when}); read it first.`
482  // Not awaited: it resolves when the new turn starts.
483  $.prompt.submit({ text }).catch(() => {
484    toast($, `handoff-guard: could not start the resume turn; read .claude/brain/project.md (written ${p.when})`)
485  })
486  return true
487}
488
489/** Resets per-session state and reloads what a fresh session needs (after start or /clear). */
490async function onStart($: Api, cwd: string): Promise<void> {
491  if (cwd) await update($, cwdAtom, () => cwd)
492  unmeasured = 0
493  await loadConfig($)
494  await loadLast($)
495  const r = await measure($, 'start')
496  if (r) await evaluate($, r, 'start')
497}
498
499// ---------------------------------------------------------------- pane helpers (pure)
500
501/** The quiet color of the kit (secondary text), a theme key. */
502const QUIET = TONES.muted.fg
503
504/** The pane's hero line: the handoff state, in the words the band uses. */
505function heroTitle(r: Reading | null, cfg: Config, o: { busy: boolean; sessionOff: boolean }): string {
506  if (o.busy) return 'Writing the handoff…'
507  if (!r) return 'Context: no reading yet'
508  const ctx = `Context ${pct(r.frac)}%`
509  const level = levelOf(r.frac, cfg)
510  if (level === 'due') {
511    if (cfg.mode === 'off' || o.sessionOff) return `${ctx} · handoff due · guard off`
512    return cfg.mode === 'auto' ? `${ctx} · handoff due · auto handoff` : `${ctx} · handoff due: /handoff clear`
513  }
514  if (level === 'warn') return `${ctx} · /handoff at a stopping point`
515  return `${ctx} · next handoff at ${pct(cfg.handoffAt)}%`
516}
517
518// ---------------------------------------------------------------- /handoff
519
520async function statusText($: Api): Promise<string> {
521  const cfg = await read($, configAtom)
522  const r = await read($, readingAtom)
523  const last = await read($, lastBriefAtom)
524  const off = await read($, sessionOffAtom)
525  const ctx = r
526    ? `Context ${pct(r.frac)}%` + (r.tokens !== null && r.window !== null ? ` (${r.tokens} of ${r.window} tokens)` : '')
527    : 'Context: no reading yet'
528  const lines = [
529    `${ctx} · warn ${pct(cfg.warnAt)}% · handoff ${pct(cfg.handoffAt)}% · mode ${cfg.mode}${off ? ' (off this session)' : ''}`,
530    last ? `Last handoff: ${last.when} (${last.source}) at ${last.path}` : 'Last handoff: none for this project yet',
531  ]
532  return lines.join('\n')
533}
534
535async function runCommand($: Api, args: string): Promise<{ text?: string }> {
536  const words = args.trim().split(/\s+/).filter(Boolean)
537  const sub = (words[0] ?? '').toLowerCase()
538  const arg = words.slice(1).join(' ').toLowerCase()
539  if (sub === '' || sub === 'now' || sub === 'write') {
540    const info = await doHandoff($, false)
541    return { text: info ? `Handoff written to ${info.path} (${info.source}).` : 'handoff-guard: no handoff written (one is already running, or it failed).' }
542  }
543  if (sub === 'clear') {
544    const info = await doHandoff($, true)
545    return {
546      text: info
547        ? `Handoff written to ${info.path} (${info.source}). Clearing; the next session resumes from it.`
548        : 'handoff-guard: no handoff written, so nothing was cleared.',
549    }
550  }
551  if (sub === 'status') return { text: await statusText($) }
552  if (sub === 'show' || sub === 'pane') {
553    await $.ui.open({ id: PANE, title: 'Handoff guard', focus: true, closeOnEscape: true })
554    return {}
555  }
556  if (sub === 'mode') {
557    if (!(MODES as readonly string[]).includes(arg)) return { text: 'Mode must be ask, auto or off.' }
558    const cfg = await saveSetting($, { mode: arg as Config['mode'] })
559    if (arg !== 'off') await update($, sessionOffAtom, () => false)
560    return { text: cfg.mode === arg ? `Mode set to ${arg}.` : `Mode saved as ${arg}, but a config file overrides it: ${cfg.mode}.` }
561  }
562  if (sub === 'at' || sub === 'warn') {
563    const f = parseFraction(arg)
564    if (f === null) return { text: 'Give a fraction between 0 and 1, such as 0.85.' }
565    const cfg = await saveSetting($, sub === 'at' ? { handoffAt: f } : { warnAt: f })
566    if (sub === 'at') {
567      await update($, nextAskAtom, () => 0)
568      await update($, askedOutAtom, () => false)
569      await update($, autoTriedAtom, () => false)
570    } else {
571      await update($, warnedAtom, () => false)
572    }
573    return { text: `Warning at ${pct(cfg.warnAt)}%, handoff at ${pct(cfg.handoffAt)}%.` }
574  }
575  if (sub === 'band') {
576    if (arg !== 'on' && arg !== 'off') return { text: 'Use /handoff band on or /handoff band off.' }
577    const cfg = await saveSetting($, { band: arg === 'on' })
578    return { text: cfg.band ? 'Band shown above the prompt.' : 'Band hidden.' }
579  }
580  return { text: HELP }
581}
582
583// ---------------------------------------------------------------- hooks
584
585export function register(on: On) {
586  on('session.start', async ($, e, next) => {
587    try {
588      await onStart($, typeof e.cwd === 'string' ? e.cwd : '')
589    } catch {
590      // Defaults stay in place.
591    }
592    // The clear signal can fire before this module loads (a reload at the wrong moment): a
593    // fresh queued resume and an empty transcript count too (session-saver's race guard).
594    try {
595      if ((await messagesOf($)).length === 0) await consumePending($, RACE_TTL_MS)
596    } catch {
597      // The pending entry stays for classic.SessionStart.
598    }
599    try {
600      await $.command.register({
601        name: 'handoff',
602        description: 'Write the brain handoff now; or clear, status, show, mode, at, warn, band',
603        argumentHint: '[clear | status | show | mode ask|auto|off | at <0-1> | warn <0-1> | band on|off]',
604        immediate: true,
605      })
606    } catch {
607      // The name is taken: measuring and the band still work.
608    }
609    return next(e)
610  })
611
612  // /clear (and /resume, /branch) reset $.state without a session.start.
613  on('classic.SessionStart', async ($, e, next) => {
614    const result = await next(e)
615    const source = typeof e.source === 'string' ? e.source : ''
616    if (source === 'clear' || source === 'resume' || source === 'compact') {
617      try {
618        await onStart($, typeof e.cwd === 'string' ? e.cwd : '')
619      } catch {
620        // Defaults stay in place.
621      }
622    }
623    if (source === 'clear') {
624      try {
625        await consumePending($, PENDING_TTL_MS)
626      } catch {
627        // Nothing queued.
628      }
629    }
630    return result
631  })
632
633  on('prompt.submit', async ($, e, next) => {
634    try {
635      const kind = e.origin && typeof e.origin === 'object' ? (e.origin as { kind?: string; asUser?: boolean }) : undefined
636      const typed = !kind || ['composer', 'bridge', 'sdk'].includes(String(kind.kind)) || kind.asUser === true
637      if (typed && typeof e.text === 'string' && !isMeta(e.text) && (await read($, resumedAtom))) {
638        await update($, resumedAtom, () => '')
639      }
640    } catch {
641      // Display only.
642    }
643    return next(e)
644  })
645
646  on('turn.start', async ($, e, next) => {
647    try {
648      if (!e.agentId) await update($, editedTurnAtom, () => [])
649    } catch {
650      // Observing only.
651    }
652    return next(e)
653  })
654
655  on('turn.step', async function* ($, e, next) {
656    if (!e.agentId) {
657      let stop = false
658      try {
659        const r = await measure($, 'step', Math.ceil(unmeasured))
660        // Index 0 follows a turn.complete reading; act on the requests after tool calls.
661        if (r && typeof e.index === 'number' && e.index > 0) stop = (await evaluate($, r, 'step')) === 'stop'
662      } catch {
663        stop = false
664      }
665      if (stop) {
666        unmeasured = 0
667        yield { kind: 'text', index: 0, text: '[handoff-guard] Context is nearly full: the handoff is written to .claude/brain/ and this session is clearing.' } as never
668        yield { kind: 'stop', stopReason: 'end_turn', usage: null } as never
669        return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null } as never
670      }
671    }
672    const result = yield* next(e)
673    if (!e.agentId) unmeasured = 0
674    return result
675  })
676
677  on('turn.complete', async ($, e, next) => {
678    const result = await next(e)
679    if (!e.agentId) {
680      try {
681        unmeasured = 0
682        const r = await measure($, 'complete')
683        if (r) await evaluate($, r, 'complete')
684      } catch {
685        // Observing only.
686      }
687    }
688    return result
689  })
690
691  on('tool.call', async ($, e, next) => {
692    let deny: string | null = null
693    try {
694      const tool = typeof e.tool === 'string' ? e.tool : ''
695      const exempt = tool === 'AskUserQuestion' || tool.startsWith('mcp__handoff-guard__') || (typeof e.consent === 'string' && e.consent !== '')
696      if (!exempt) {
697        const cfg = await read($, configAtom)
698        // Only auto mode refuses, so only auto mode pays for a fresh reading here.
699        const r = cfg.mode === 'auto' && !e.agentId ? ((await measure($, 'tool', Math.ceil(unmeasured))) ?? (await read($, readingAtom))) : null
700        if (r && !(await read($, sessionOffAtom)) && r.frac >= cfg.denyAt) {
701          deny = `[handoff-guard] Not run: the context is at ${pct(r.frac)}%, past ${pct(cfg.denyAt)}%. The handoff to .claude/brain/ is being written and this session will clear; the next session redoes this call. Make no more tool calls: end with one line saying where you stopped.`
702        }
703      }
704    } catch {
705      deny = null
706    }
707    if (deny) return { deny }
708    const result = await next(e)
709    try {
710      const tool = typeof e.tool === 'string' ? e.tool : ''
711      const ok = result && typeof result === 'object' && !('deny' in result && result.deny) && !(result as { isError?: boolean }).isError
712      const path = (e as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path
713      if (ok && EDIT_TOOLS.has(tool) && typeof path === 'string') {
714        await update($, editedSessionAtom, (xs: string[]) => [...xs.filter(x => x !== path), path].slice(-50))
715        await update($, editedTurnAtom, (xs: string[]) => [...xs.filter(x => x !== path), path].slice(-50))
716      }
717      if (!e.agentId && result && typeof result === 'object') {
718        const t = (result as { text?: unknown }).text ?? (result as { result?: unknown }).result
719        if (typeof t === 'string') unmeasured += t.length / CHARS_PER_TOKEN
720      }
721    } catch {
722      // Observing only.
723    }
724    return result
725  })
726
727  on('session.compact', async ($, e, next) => {
728    if (e.agentId || e.trigger === 'precompute') return next(e)
729    try {
730      const cfg = await read($, configAtom)
731      if (cfg.mode !== 'off' && !(await read($, sessionOffAtom)) && !(await read($, writtenAtom)) && !inFlight) {
732        await doHandoff($, false)
733      }
734    } catch {
735      // Never block compaction.
736    }
737    const result = await next(e)
738    try {
739      if (result && typeof result === 'object' && !('skip' in result)) {
740        // Compaction is a fresh start for the context: the next one writes again.
741        await update($, writtenAtom, () => false)
742        await measure($, 'compact')
743      }
744    } catch {
745      // Observing only.
746    }
747    return result
748  })
749
750  on('command.run', { command: 'handoff' }, async ($, e) => {
751    try {
752      return await runCommand($, typeof e.args === 'string' ? e.args : '')
753    } catch (err) {
754      return { text: 'handoff-guard: ' + errText(err) }
755    }
756  })
757
758  // The band, one kit line: a bold label, the context chip in the level's tone (ok, accent, danger;
759  // muted before the first reading), then what to do and the last handoff on the right side: quiet,
760  // or in the default text color once the handoff is due (kit.line draws its right side quiet).
761  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
762    const props = (e.props ?? {}) as { hasSurvey?: boolean }
763    const cfg = await read($, configAtom)
764    if (props.hasSurvey === true || !cfg.band) return next(e)
765    const r = await read($, readingAtom)
766    const last = await read($, lastBriefAtom)
767    const parts = bandParts(r, cfg, {
768      lastWhen: last ? last.when : undefined,
769      busy: await read($, busyAtom),
770      sessionOff: await read($, sessionOffAtom),
771      resumed: (await read($, resumedAtom)) || undefined,
772    })
773    const ui = $.ui.resolve(e)
774    const { Box } = ui
775    const kit = makeKit(ui as unknown as Elements, e.surface)
776    const chips = [{ text: parts.chip, tone: parts.tone }]
777    const right = parts.right.join(' · ')
778    const drawn = (
779      parts.urgent && right
780        ? Box({
781            flexDirection: 'row',
782            justifyContent: 'space-between',
783            columnGap: 2,
784            children: [kit.line('handoff', chips) as ReturnType<typeof Box>, kit.text(right, { wrap: 'truncate' }) as ReturnType<typeof Box>],
785          })
786        : kit.line('handoff', chips, parts.right)
787    ) as ReturnType<typeof Box>
788    const line = Box({ key: 'handoff-guard-band', flexDirection: 'column', children: [drawn] })
789    const theirs = await next(e)
790    if (!theirs) return line
791    return Box({ flexDirection: 'column', children: [line, theirs] })
792  })
793
794  // The pane, drawn with the kit: a hero for the handoff state, tiles, the readings, the last brief, buttons.
795  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
796    const cfg = await read($, configAtom)
797    const r = await read($, readingAtom)
798    const history = await read($, historyAtom)
799    const last = await read($, lastBriefAtom)
800    const busy = await read($, busyAtom)
801    const off = await read($, sessionOffAtom)
802    const ui = $.ui.resolve(e)
803    const kit = makeKit(ui as unknown as Elements, e.surface)
804    const level = r ? levelOf(r.frac, cfg) : 'ok'
805    const tone: Tone = r ? LEVEL_TONE[level] : 'muted'
806
807    const hero = kit.hero({
808      kicker: 'handoff guard',
809      title: heroTitle(r, cfg, { busy, sessionOff: off }),
810      sub: [
811        r && r.tokens !== null && r.window !== null ? `${tokensShort(r.tokens)} of ${tokensShort(r.window)} tokens` : '',
812        `mode ${cfg.mode}${off ? ' (off this session)' : ''}`,
813        last ? `last handoff ${last.when}` : 'no handoff yet',
814      ]
815        .filter(Boolean)
816        .join(' · '),
817      tone: busy ? 'info' : tone,
818    })
819
820    const tiles = kit.tiles([
821      { label: 'Context', value: r ? `${pct(r.frac)}%` : '--', tone },
822      { label: 'Warn at', value: `${pct(cfg.warnAt)}%`, tone: LEVEL_TONE.warn },
823      { label: 'Handoff at', value: `${pct(cfg.handoffAt)}%`, tone: 'danger' },
824      { label: 'Mode', value: off ? `${cfg.mode} (off)` : cfg.mode, tone: cfg.mode === 'off' || off ? 'muted' : cfg.mode === 'auto' ? 'accent' : 'info' },
825    ])
826
827    const shown = history.slice(-10)
828    const readings: unknown[] = shown.length
829      ? shown.map((h, i) => {
830          const lv = levelOf(h.frac, cfg)
831          return kit.row({
832            key: `reading-${i}`,
833            left: [kit.pill(lv.padEnd(4), LEVEL_TONE[lv]), whenOf(h.at).slice(11), kit.text(h.where.padEnd(8), { color: QUIET })],
834            right: [kit.bar(Math.min(1, h.frac), { width: 20, tone: LEVEL_TONE[lv] })],
835          })
836        })
837      : [kit.text('none yet', { color: QUIET })]
838
839    const brief: unknown[] = last
840      ? [
841          kit.row({
842            left: [
843              kit.pill(last.source, last.source === 'facts' ? 'accent' : 'ok'),
844              kit.text(last.path, { color: QUIET, wrap: 'truncate-start' }),
845            ],
846          }),
847          last.flagged.length ? kit.row({ left: [kit.pill('unverified', 'accent'), last.flagged.join(', ')] }) : null,
848          ui.Markdown({ text: last.preview.split('\n').slice(0, 60).join('\n') }),
849        ]
850      : [kit.text('No handoff written for this project yet. /handoff writes one.', { color: QUIET })]
851
852    return kit.page([
853      hero,
854      tiles,
855      // A full-width Svg bar where the surface draws Svg, the 40-column text bar on the terminal.
856      r ? kit.progress(Math.min(1, r.frac), kit.surface === 'terminal' ? { width: 40, tone } : { tone }) : null,
857      kit.stack([kit.header(`Readings · ${history.length}`, history.length > shown.length ? `last ${shown.length}` : undefined), ...readings]),
858      kit.stack([kit.header('Last handoff', last ? last.when : 'none yet'), ...brief]),
859      kit.buttons([
860        { key: 'write', label: 'Write handoff', hotkey: 'w', plain: true, onPress: () => doHandoff($, false) },
861        { key: 'close', label: 'Close', hotkey: 'c', plain: true, onPress: () => $.ui.close({ id: PANE }) },
862      ]),
863    ]) as never
864  })
865}
866
hooks/logic.ts 608 lines
1// handoff-guard: pure logic, no $ calls, so every rule here is unit tested on its own.
2//
3// Config merge and thresholds, the band, fact extraction from the transcript, the brief
4// template and its validation, the unverified-figures pass, and the text transforms that
5// write the handoff files (.claude/brain/{project,log,decisions,questions}.md) in the
6// handoff folder format the README describes.
7
8import type { Config, Mode, Reading } from '../types'
9
10export const MODES: readonly Mode[] = ['ask', 'auto', 'off']
11
12export const DEFAULTS: Config = {
13  warnAt: 0.7,
14  handoffAt: 0.85,
15  reaskAt: 0.92,
16  denyAt: 0.95,
17  mode: 'ask',
18  model: 'haiku',
19  maxBriefTokens: 2000,
20  band: true,
21}
22
23export const HELP = [
24  '/handoff                 write the handoff into .claude/brain/ now (no clear)',
25  '/handoff clear           write the handoff, then /clear and resume from it',
26  '/handoff status          context fill, last handoff time and path',
27  '/handoff show            pane: last brief and readings',
28  '/handoff mode ask|auto|off',
29  '/handoff at <0-1>        handoff level (default 0.85)',
30  '/handoff warn <0-1>      warning level (default 0.70)',
31  '/handoff band on|off',
32].join('\n')
33
34// ---------------------------------------------------------------- config
35
36function frac(v: unknown): number | undefined {
37  return typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= 1 ? v : undefined
38}
39
40/** Keeps only the known, well-typed fields of one config layer. */
41export function sanitize(raw: unknown): Partial<Config> {
42  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
43  const r = raw as Record<string, unknown>
44  const out: Partial<Config> = {}
45  for (const k of ['warnAt', 'handoffAt', 'reaskAt', 'denyAt'] as const) {
46    const f = frac(r[k])
47    if (f !== undefined) out[k] = f
48  }
49  if (typeof r.mode === 'string' && (MODES as readonly string[]).includes(r.mode)) out.mode = r.mode as Mode
50  if (typeof r.model === 'string' && r.model.trim()) out.model = r.model.trim()
51  if (typeof r.maxBriefTokens === 'number' && Number.isFinite(r.maxBriefTokens)) {
52    out.maxBriefTokens = Math.min(8000, Math.max(300, Math.round(r.maxBriefTokens)))
53  }
54  if (typeof r.band === 'boolean') out.band = r.band
55  return out
56}
57
58/** Defaults, then each layer in order (user file, project file, $.store), later wins. */
59export function mergeConfig(...layers: unknown[]): Config {
60  let cfg: Config = { ...DEFAULTS }
61  for (const l of layers) cfg = { ...cfg, ...sanitize(l) }
62  // Keep the levels in order: warn <= handoff <= reask <= deny.
63  if (cfg.warnAt > cfg.handoffAt) cfg.warnAt = cfg.handoffAt
64  if (cfg.reaskAt < cfg.handoffAt) cfg.reaskAt = Math.min(1, cfg.handoffAt + 0.05)
65  if (cfg.denyAt < cfg.reaskAt) cfg.denyAt = Math.min(1, Math.max(cfg.reaskAt, cfg.handoffAt + 0.1))
66  return cfg
67}
68
69/** "0.8", "80" or "80%" as 0.8; null when it is not in (0, 1]. */
70export function parseFraction(arg: string): number | null {
71  const s = arg.trim()
72  if (!s) return null
73  const pct = s.endsWith('%')
74  const n = Number(pct ? s.slice(0, -1) : s)
75  if (!Number.isFinite(n)) return null
76  const f = pct || n > 1 ? n / 100 : n
77  return f > 0 && f <= 1 ? Math.round(f * 1000) / 1000 : null
78}
79
80// ---------------------------------------------------------------- readings and levels
81
82export type Level = 'ok' | 'warn' | 'due'
83
84/** A usage() context block as a reading; null when it carries no usable figure. */
85export function readingOf(context: unknown, at: number, where: string, extraTokens = 0): Reading | null {
86  if (!context || typeof context !== 'object') return null
87  const c = context as { tokens?: unknown; window?: unknown; percent?: unknown }
88  const tokens = typeof c.tokens === 'number' && Number.isFinite(c.tokens) ? c.tokens : null
89  const window = typeof c.window === 'number' && Number.isFinite(c.window) && c.window > 0 ? c.window : null
90  let f: number | null = null
91  if (tokens !== null && window !== null) f = (tokens + Math.max(0, extraTokens)) / window
92  else if (typeof c.percent === 'number' && Number.isFinite(c.percent)) f = c.percent / 100
93  if (f === null || !Number.isFinite(f) || f < 0) return null
94  return { tokens: tokens === null ? null : tokens + Math.max(0, extraTokens), window, frac: Math.min(f, 1.5), at, where }
95}
96
97export function levelOf(f: number, cfg: Config): Level {
98  if (f >= cfg.handoffAt) return 'due'
99  if (f >= cfg.warnAt) return 'warn'
100  return 'ok'
101}
102
103export const pct = (f: number) => Math.round(f * 100)
104
105/** The kit tone of a level (the kit's Tone names): ok, accent (the Claude orange, not the warning
106 *  key) at warnAt, danger at handoffAt. */
107export const LEVEL_TONE: Record<Level, 'ok' | 'accent' | 'danger'> = { ok: 'ok', warn: 'accent', due: 'danger' }
108
109/** The band's parts: the chip (`ctx 41%`), its kit tone (muted before the first reading), the right
110 *  side (what to do, then the last or resumed handoff) and whether it is urgent: at or past
111 *  handoffAt the right side is drawn in the default text color, else quiet. */
112export type BandParts = { chip: string; tone: 'ok' | 'accent' | 'danger' | 'muted'; right: string[]; urgent: boolean }
113
114/** The one-line band, drawn as kit.line('handoff', [chip], right): `handoff  ● ctx 41%   last 14:02`. */
115export function bandParts(
116  r: Reading | null,
117  cfg: Config,
118  o: { lastWhen?: string; busy?: boolean; sessionOff?: boolean; resumed?: string } = {},
119): BandParts {
120  if (!r) return { chip: 'ctx --', tone: 'muted', right: o.lastWhen ? [`last ${shortWhen(o.lastWhen)}`] : [], urgent: false }
121  const level = levelOf(r.frac, cfg)
122  const bits: string[] = []
123  if (o.busy) bits.push('writing handoff…')
124  else if (level === 'due') {
125    if (cfg.mode === 'off' || o.sessionOff) bits.push('handoff due · guard off')
126    else bits.push(cfg.mode === 'auto' ? 'auto handoff' : 'handoff due: /handoff clear')
127  } else if (level === 'warn') bits.push('/handoff at a stopping point')
128  if (o.resumed) bits.push(`resumed from handoff ${shortWhen(o.resumed)}`)
129  else if (o.lastWhen) bits.push(`last ${shortWhen(o.lastWhen)}`)
130  return { chip: `ctx ${pct(r.frac)}%`, tone: LEVEL_TONE[level], right: bits, urgent: level === 'due' }
131}
132
133/** "2026-10-09 14:02" as "14:02" when it is today's, else as written. */
134export function shortWhen(when: string, today?: string): string {
135  if (today && when.startsWith(today + ' ')) return when.slice(today.length + 1)
136  return when
137}
138
139// ---------------------------------------------------------------- time
140
141const two = (n: number) => String(n).padStart(2, '0')
142
143export function dateOf(ms: number): string {
144  const d = new Date(ms)
145  return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`
146}
147
148export function whenOf(ms: number): string {
149  const d = new Date(ms)
150  return `${dateOf(ms)} ${two(d.getHours())}:${two(d.getMinutes())}`
151}
152
153// ---------------------------------------------------------------- facts from the transcript
154
155/** The slice of a $.session.messages() row this mod reads. */
156export type Msg = {
157  role: string
158  text: string
159  toolUses?: readonly { tool: string; input?: Record<string, unknown>; result?: unknown; text?: string; isError?: boolean }[]
160}
161
162// Harness signals that arrive as user messages but are never the user's words (from auto-handoff).
163const META_PREFIXES = [
164  'Stop hook feedback',
165  '[handoff-guard]',
166  'Resume from the handoff in .claude/brain/',
167  '[Image',
168  '<system-reminder>',
169  '<command-name>',
170  '<local-command',
171  '<task-notification>',
172  '[Request interrupted',
173]
174
175export const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
176
177export function isMeta(text: string): boolean {
178  const t = text.trim()
179  return META_PREFIXES.some(p => t.startsWith(p))
180}
181
182export type Transcript = {
183  lastPrompt: string
184  lastAnswer: string
185  openQuestions: string[]
186  edited: string[]
187}
188
189/** The last real prompt, the last answer, unanswered AskUserQuestion calls and edited files. */
190export function extractFromMessages(messages: readonly Msg[]): Transcript {
191  let lastPrompt = ''
192  let lastAnswer = ''
193  const open: string[] = []
194  const answered = new Set<string>()
195  const edited = new Set<string>()
196  for (const m of Array.isArray(messages) ? messages : []) {
197    if (!m || typeof m !== 'object') continue
198    const text = typeof m.text === 'string' ? m.text.trim() : ''
199    if (m.role === 'user' && text && !isMeta(text)) lastPrompt = text
200    if (m.role === 'assistant' && text) lastAnswer = text
201    for (const t of Array.isArray(m.toolUses) ? m.toolUses : []) {
202      if (!t || typeof t.tool !== 'string') continue
203      const input = t.input && typeof t.input === 'object' ? t.input : {}
204      const path = input.file_path ?? input.notebook_path
205      if (EDIT_TOOLS.has(t.tool) && typeof path === 'string' && !t.isError) edited.add(path)
206      if (t.tool === 'AskUserQuestion') {
207        const qs = Array.isArray(input.questions) ? input.questions : []
208        const unanswered = t.isError === true || (t.result === undefined && t.text === undefined)
209        for (const q of qs) {
210          const s = q && typeof q === 'object' && typeof (q as { question?: unknown }).question === 'string' ? (q as { question: string }).question.trim() : ''
211          if (!s) continue
212          if (unanswered) open.push(s)
213          else answered.add(s)
214        }
215      }
216    }
217  }
218  const openQuestions = [...new Set(open.filter(q => !answered.has(q)))].slice(-5)
219  return { lastPrompt, lastAnswer, openQuestions, edited: [...edited] }
220}
221
222export type Facts = {
223  cwd: string
224  when: string
225  branch: string
226  commits: string[]
227  editedTurn: string[]
228  editedSession: string[]
229  lastPrompt: string
230  lastAnswer: string
231  openQuestions: string[]
232  stage: string
233  ctxPercent: number | null
234  ctxTokens: number | null
235  ctxWindow: number | null
236}
237
238export function clip(text: string, max: number): string {
239  const t = text.replace(/\s+/g, ' ').trim()
240  return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t
241}
242
243/** Strips credential-shaped strings before anything is written or sent. */
244export function redact(text: string): string {
245  return text
246    .replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)[A-Z0-9_]*)\s*[=:]\s*\S+/g, '$1=[redacted]')
247    .replace(/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})/g, '[redacted]')
248    .replace(/\b[A-Za-z0-9_-]{40,}\b/g, m => (/^[0-9a-f]{40}$/.test(m) ? m : '[redacted]'))
249}
250
251function rel(path: string, cwd: string): string {
252  const base = cwd.replace(/\/+$/, '') + '/'
253  return cwd && path.startsWith(base) ? path.slice(base.length) : path
254}
255
256/** The facts block: everything here came from code, not from a model. */
257export function factsBlock(f: Facts): string {
258  const list = (xs: string[], empty = 'None.') => (xs.length ? xs.map(x => `- ${x}`).join('\n') : empty)
259  const ctx =
260    f.ctxPercent === null
261      ? 'unknown'
262      : `${f.ctxPercent}%` + (f.ctxTokens !== null && f.ctxWindow !== null ? ` (${f.ctxTokens} of ${f.ctxWindow} tokens)` : '')
263  const lines = [
264    '## Facts (from code)',
265    `- Written: ${f.when}`,
266    `- Working directory: ${f.cwd || 'unknown'}`,
267    `- Git branch: ${f.branch || 'not a git repository'}`,
268    `- Context at handoff: ${ctx}`,
269  ]
270  if (f.stage) lines.push(`- Pipeline stage: ${f.stage}`)
271  lines.push('', '### Last commits', list(f.commits))
272  lines.push('', '### Files edited this turn', list(f.editedTurn.map(p => rel(p, f.cwd))))
273  const earlier = f.editedSession.filter(p => !f.editedTurn.includes(p))
274  lines.push('', '### Files edited earlier this session', list(earlier.slice(-20).map(p => rel(p, f.cwd))))
275  lines.push('', '### Unanswered questions to the user', list(f.openQuestions))
276  lines.push('', '### Last user request', f.lastPrompt ? clip(redact(f.lastPrompt), 600) : 'None found.')
277  lines.push('', '### Last answer (excerpt)', f.lastAnswer ? clip(redact(f.lastAnswer), 600) : 'None found.')
278  return lines.join('\n')
279}
280
281// ---------------------------------------------------------------- the brief
282
283export const HEADINGS = ['Status', 'Next steps', 'Dead ends', 'Open questions', 'Decisions', 'Log'] as const
284export type Heading = (typeof HEADINGS)[number]
285
286export function briefTemplate(maxWords: number): string {
287  return `Write the judgment sections of a session handoff for the next session in this project. Use exactly these six headings, in this order, and nothing before the first:
288
289## Status
290Two to four sentences: where the work stands, what works, what is broken.
291
292## Next steps
293- One to three concrete next steps, most important first.
294
295## Dead ends
296- Approaches tried and dropped this session, with the reason. Write "None." if there were none.
297
298## Open questions
299- Questions only the human can answer. Write "None." if there are none.
300
301## Decisions
302- One line per decision someone outside this repo should know, as a pointer (a file, an ADR or a commit), not the reasoning. Write "None." if there are none.
303
304## Log
305One line, past tense, under 250 characters: what changed. Next: the next step. Blocked: only when something is.
306
307Rules: use only what the conversation and the Facts block support. Copy any number exactly from the Facts block and write no other figures. No secrets, tokens, credentials, phone numbers or personal names. Plain sentences, no em dashes. Under ${maxWords} words in total.`
308}
309
310export function wordsFor(maxBriefTokens: number): number {
311  return Math.max(120, Math.min(600, Math.round(maxBriefTokens * 0.3)))
312}
313
314/** The fork's one user message: it already sees the conversation, so only facts and the template. */
315export function forkPrompt(facts: string, maxWords: number): string {
316  return `[handoff-guard] A side request from a mod, not from the user, and not part of the task: the context window is nearly full, so the session is writing its handoff before it clears. Do not call tools and do not continue the task. Reply with the handoff only.
317
318${facts}
319
320---
321
322${briefTemplate(maxWords)}`
323}
324
325/** A compact transcript for the $.model.complete fallback, which has no conversation of its own. */
326export function renderTranscript(messages: readonly Msg[], maxChars = 60_000): string {
327  const rows = (Array.isArray(messages) ? messages : []).slice(-80).map(m => {
328    const role = m.role === 'user' && isMeta(String(m.text ?? '')) ? 'system signal (not the user)' : m.role
329    const text = clip(redact(String(m.text ?? '')), 1500)
330    const tools = (Array.isArray(m.toolUses) ? m.toolUses : []).map(
331      t => `  [tool ${t.tool}${t.isError ? ' ERROR' : ''}] ${clip(redact(JSON.stringify(t.input ?? {})), 200)}`,
332    )
333    return [`### ${role}`, text, ...tools].filter(Boolean).join('\n')
334  })
335  const joined = rows.join('\n\n')
336  return joined.length > maxChars ? joined.slice(-maxChars) : joined
337}
338
339export function completePrompt(facts: string, transcript: string, maxWords: number): string {
340  // Data first, instructions last (as auto-handoff does).
341  return `${facts}\n\n## Conversation (most recent part)\n${transcript || '(empty)'}\n\n---\n\n${briefTemplate(maxWords)}`
342}
343
344export type Parsed = Record<Heading, string>
345
346/** The reply's six sections, or null when any heading is missing (then the facts stand alone). */
347export function parseBrief(text: unknown): Parsed | null {
348  if (typeof text !== 'string' || !text.trim()) return null
349  const t = text.replace(/^```[a-z]*\n?/i, '').replace(/\n?```\s*$/, '')
350  const re = /^#{1,4}\s*\**\s*(Status|Next steps|Dead ends|Open questions|Decisions|Log)\s*\**\s*:?\s*$/gim
351  const marks: { h: Heading; start: number; end: number }[] = []
352  let m: RegExpExecArray | null
353  while ((m = re.exec(t)) !== null) {
354    const h = HEADINGS.find(x => x.toLowerCase() === m![1].toLowerCase())
355    if (h) marks.push({ h, start: m.index, end: m.index + m[0].length })
356  }
357  const out: Partial<Parsed> = {}
358  marks.forEach((mk, i) => {
359    if (out[mk.h] !== undefined) return
360    out[mk.h] = t.slice(mk.end, i + 1 < marks.length ? marks[i + 1].start : t.length).trim()
361  })
362  return HEADINGS.every(h => out[h] !== undefined) ? (out as Parsed) : null
363}
364
365const NONE = /^(none|n\/a|nothing|no\b.*(?:dead ends?|questions?|decisions?))\.?$/i
366
367/** Bullet lines of a section ("None." gives []); prose without bullets is one item. */
368export function itemsOf(section: string): string[] {
369  const lines = section.split('\n').map(l => l.trim()).filter(Boolean)
370  const bullets = lines.filter(l => /^([-*•]|\d+[.)])\s+/.test(l)).map(l => l.replace(/^([-*•]|\d+[.)])\s+/, '').trim())
371  const items = bullets.length ? bullets : lines.length ? [lines.join(' ')] : []
372  return items.map(i => i.replace(/\s+/g, ' ').trim()).filter(i => i && !NONE.test(i))
373}
374
375// ---------------------------------------------------------------- unverified figures
376
377// A figure: 93, 93.5, 1,200, 72%, 93k, 3x. Not part of a word, path, version-free id or date.
378const FIGURE = /(?<![\w.\/#:@-])(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)(%|k\b|K\b|x\b)?(?![\w\/]|\.\d|-\d)/g
379
380function valueOf(num: string, suffix: string | undefined): number {
381  const n = Number(num.replace(/,/g, ''))
382  return suffix === 'k' || suffix === 'K' ? n * 1000 : n
383}
384
385/** Masks spans whose numbers are not claims: inline code, ISO dates, times, versions. */
386function maskSpans(text: string): string {
387  return text
388    .replace(/`[^`\n]*`/g, s => ' '.repeat(s.length))
389    .replace(/\b\d{4}-\d{2}-\d{2}\b/g, s => ' '.repeat(s.length))
390    .replace(/\b\d{1,2}:\d{2}\b/g, s => ' '.repeat(s.length))
391    .replace(/\bv?\d+\.\d+\.\d+\b/g, s => ' '.repeat(s.length))
392}
393
394/**
395 * Marks every figure in the model's text that the facts do not hold, by appending
396 * " (unverified)". Single-digit counts (step 1, 3 files) are left alone. Borrowed from
397 * auto-handoff's markUnverifiedFigures, widened from token figures to any number.
398 */
399export function markUnverifiedFigures(text: string, facts: string): { text: string; flagged: string[] } {
400  const allowed: number[] = []
401  for (const m of maskSpans(facts).matchAll(FIGURE)) allowed.push(valueOf(m[1], m[2]))
402  for (const m of facts.matchAll(/\d+(?:\.\d+)?/g)) allowed.push(Number(m[0]))
403  const masked = maskSpans(text)
404  const flagged: string[] = []
405  const cuts: { at: number; fig: string }[] = []
406  for (const m of masked.matchAll(FIGURE)) {
407    const v = valueOf(m[1], m[2])
408    if (!m[2] && Number.isInteger(v) && v < 10) continue
409    const k = m[2] === 'k' || m[2] === 'K'
410    if (allowed.some(a => (k ? Math.abs(a - v) < 1000 : a === v))) continue
411    flagged.push(m[0])
412    cuts.push({ at: (m.index ?? 0) + m[0].length, fig: m[0] })
413  }
414  let out = text
415  for (const c of cuts.reverse()) out = out.slice(0, c.at) + ' (unverified)' + out.slice(c.at)
416  return { text: out, flagged }
417}
418
419// ---------------------------------------------------------------- sections for the brain files
420
421export type Sections = {
422  /** Body of `## Status today`. */
423  status: string
424  /** Body of `## Next` (bullets). */
425  next: string
426  /** Body of `## Dead ends`, or null to leave the section as it is. */
427  deadEnds: string | null
428  /** One log line, without the date prefix. */
429  log: string
430  decisions: string[]
431  questions: string[]
432}
433
434const bullets = (xs: string[]) => xs.map(x => `- ${x}`).join('\n')
435
436function ctxPhrase(f: Facts): string {
437  return f.ctxPercent === null ? '' : `, context ${f.ctxPercent}%`
438}
439
440function atCtx(f: Facts): string {
441  return f.ctxPercent === null ? '' : ` at ${f.ctxPercent}% context`
442}
443
444/** What goes into the files: the model's sections when they parsed, else facts only. */
445export function buildSections(parsed: Parsed | null, f: Facts): Sections {
446  const stamp = `(Written by handoff-guard ${f.when}${ctxPhrase(f)}${f.branch ? `, branch ${f.branch}` : ''}${parsed ? '' : ', facts only'}.)`
447  const qs = [...f.openQuestions]
448  if (parsed) {
449    for (const q of itemsOf(parsed['Open questions'])) if (!qs.includes(q)) qs.push(q)
450    const next = itemsOf(parsed['Next steps']).slice(0, 5)
451    const dead = itemsOf(parsed['Dead ends'])
452    const log = itemsOf(parsed.Log)[0] ?? ''
453    return {
454      status: clip(parsed.Status.replace(/^[-*]\s+/gm, ''), 1200) + '\n\n' + stamp,
455      next: next.length ? bullets(next) : '- Re-read the last request in the log and continue.',
456      deadEnds: dead.length ? bullets(dead.slice(0, 6)) : null,
457      log: oneLine(log || `Handed off by handoff-guard${atCtx(f)}.`),
458      decisions: itemsOf(parsed.Decisions).slice(0, 5).map(x => oneLine(x)),
459      questions: qs.slice(0, 5).map(x => oneLine(x)),
460    }
461  }
462  const parts: string[] = []
463  if (f.commits.length) parts.push(`Last commits: ${f.commits.slice(0, 3).join('; ')}.`)
464  const edited = (f.editedTurn.length ? f.editedTurn : f.editedSession).slice(-6).map(p => rel(p, f.cwd))
465  if (edited.length) parts.push(`Edited: ${edited.join(', ')}.`)
466  if (f.stage) parts.push(`Pipeline stage: ${f.stage}.`)
467  if (f.lastPrompt) parts.push(`Last request: "${clip(redact(f.lastPrompt), 300)}"`)
468  const next = ['Re-read the last request below and check git status before continuing.']
469  if (f.lastPrompt) next.push(`Continue: ${clip(redact(f.lastPrompt), 160)}`)
470  return {
471    status: (parts.join(' ') || 'No facts could be collected.') + '\n\n' + stamp,
472    next: bullets(next),
473    deadEnds: null,
474    log: oneLine(
475      `(handoff-guard, facts only) Handed off${atCtx(f)}${edited.length ? ` after editing ${edited.slice(0, 3).join(', ')}` : ''}. Next: re-read the last request.`,
476    ),
477    decisions: [],
478    questions: qs.slice(0, 5).map(x => oneLine(x)),
479  }
480}
481
482/** The whole brief as shown in the pane: sections, then the facts. */
483export function briefMarkdown(s: Sections, facts: string): string {
484  return [
485    '## Status today',
486    s.status,
487    '',
488    '## Next',
489    s.next,
490    ...(s.deadEnds ? ['', '## Dead ends', s.deadEnds] : []),
491    '',
492    `Log: ${s.log}`,
493    ...(s.decisions.length ? ['', 'Decisions:', bullets(s.decisions)] : []),
494    ...(s.questions.length ? ['', 'Open questions:', bullets(s.questions)] : []),
495    '',
496    facts,
497  ].join('\n')
498}
499
500// ---------------------------------------------------------------- handoff folder file shapes
501
502/** One data line's text: no newlines, no leading bullet, under 300 characters (the handoff folder's line rule). */
503export function oneLine(text: string, max = 290): string {
504  return clip(redact(text.replace(/^\s*[-*]\s+/, '')), max)
505}
506
507export function datedLine(date: string, text: string): string {
508  return `- ${date} — ${oneLine(text)}`
509}
510
511export function slugOf(cwd: string): string {
512  const name = cwd.replace(/\/+$/, '').split('/').pop() ?? ''
513  return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
514}
515
516/** The handoff folder's project.md template, filled in, for a project that has no .claude/brain/ yet. */
517export function projectTemplate(slug: string, date: string): string {
518  return `---
519type: project
520name: "${slug}"
521group: ""
522status: active
523role: ""
524public: false
525live_url:
526summary: "${slug}: handoff written by handoff-guard; replace this line with what the project is."
527tags: [project, active]
528created: ${date}
529updated: ${date}
530---
531
532## What it is
533
534Not filled in yet: handoff-guard created this file. Describe the project here.
535
536## Why
537
538Not filled in yet.
539
540## Status today
541
542## Next
543
544## Dead ends
545`
546}
547
548export const FILE_HEADERS: Record<'log.md' | 'decisions.md' | 'questions.md', string> = {
549  'log.md': '# Log\n\nOne line per session, newest last: `- YYYY-MM-DD — what changed, what is next, what is blocked`.\n',
550  'decisions.md':
551    '# Decisions\n\nOne line per decision worth knowing outside this repo: `- YYYY-MM-DD — decision and why`. Append only; to reverse one, add a new dated line.\n',
552  'questions.md':
553    '# Questions\n\nOne line per question only the human can answer: `- YYYY-MM-DD — question`. Remove a line once it is answered and record the answer as a decision.\n',
554}
555
556/**
557 * Rewrites the body of `## <heading>` (up to the next `## ` or the end), keeping every
558 * other section and the frontmatter. A missing section is added at the end.
559 */
560export function replaceSection(doc: string, heading: string, body: string): string {
561  const lines = doc.replace(/\r\n/g, '\n').split('\n')
562  const head = `## ${heading}`
563  const start = lines.findIndex(l => l.trim() === head)
564  const block = [head, '', ...body.trim().split('\n'), '']
565  if (start === -1) {
566    const trimmed = lines.join('\n').replace(/\s+$/, '')
567    return trimmed + '\n\n' + block.join('\n')
568  }
569  let end = lines.length
570  for (let i = start + 1; i < lines.length; i++) {
571    if (/^## /.test(lines[i])) {
572      end = i
573      break
574    }
575  }
576  const out = [...lines.slice(0, start), ...block, ...lines.slice(end)]
577  return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
578}
579
580/** Sets `key: value` in the frontmatter block, when there is one. */
581export function setFrontmatter(doc: string, key: string, value: string): string {
582  const m = /^---\n([\s\S]*?)\n---\n/.exec(doc)
583  if (!m) return doc
584  const re = new RegExp(`^${key}:.*$`, 'm')
585  const front = re.test(m[1]) ? m[1].replace(re, `${key}: ${value}`) : `${m[1]}\n${key}: ${value}`
586  return `---\n${front}\n---\n` + doc.slice(m[0].length)
587}
588
589/** Appends a dated line unless the identical line is there (the handoff folder's append rule). */
590export function appendLine(doc: string, line: string): { doc: string; added: boolean } {
591  const body = doc.replace(/\s+$/, '')
592  if (body.split('\n').includes(line)) return { doc: body + '\n', added: false }
593  return { doc: body + '\n' + line + '\n', added: true }
594}
595
596/**
597 * The session's log line: replaces the line this session wrote before when it is still
598 * the last one (a second handoff in one session refines it), else appends.
599 */
600export function upsertLogLine(doc: string, line: string, previous: string | undefined): string {
601  const lines = doc.replace(/\s+$/, '').split('\n')
602  if (previous && lines.length && lines[lines.length - 1] === previous) {
603    lines[lines.length - 1] = line
604    return lines.join('\n') + '\n'
605  }
606  return appendLine(doc, line).doc
607}
608
hooks/kit.ts 955 lines
1// kit.ts: the shared UI kit (kit v3).
2//
3// The module has no imports and touches no mods API: every helper takes the elements a hook got
4// from `$.ui.resolve(e)` and the surface (`e.surface`) and returns an element tree.
5//
6//   const k = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
7//   return k.stack([k.hero({ kicker: 'right now', title: 'Reading client.test.js' }), k.tiles([...])], 1)
8//
9// What v2 changed on purpose (v1 painted grey fills and dimmed grey text, unreadable on the desktop):
10//   - colors are theme keys (suggestion, success, warning, error, claude, inactive) that the host
11//     tunes for light and dark; PALETTE below swaps them for named colors in one line;
12//   - no dimColor, no backgroundColor, no yellow, white, gray, black or subtle on content: secondary
13//     text is `inactive`, state is a bold `● word` chip (glyph and word, so color is never the only cue);
14//   - hero and card are one round frame (paddingX 1), tiles are single-border boxes with the label
15//     over a bold value; terminal and desktop draw the same structure (the Svg doodle is desktop-only);
16//   - kicker, section and tile labels keep the caller's case (v1 upper-cased kicker and section);
17//   - additive: line(label, chips, right?) draws one band line.
18//
19// Calibration (claude plugin test, Claude Code 2.1.294): the test harness validates the tree a
20// ui.render hook returns (a Text with a bogus prop makes `$.ui.mount` reject on both surfaces), but
21// not how the desktop paints a theme key. A Button given `plain: false` throws when built (plain and
22// autoFocus take only `true`), so the kit leaves false and undefined props out; a Box inside a Text
23// is refused. doodle() draws its Svg on the desktop only (a pane holding only an Svg opens empty in
24// the terminal).
25//
26// What v3 adds (strictly additive: every v2 export above renders as before). The desktop paints
27// Svg, and its light theme made the v1 fills unreadable, so v3 draws small pictures as Svg wherever
28// the element table has one (desktop, mobile, vscode) and as text on the terminal:
29//   - pill(text, tone?): a fully rounded Svg chip (PILL 'svg', the default); PILL 'fill' draws a
30//     theme-key backgroundColor under `inverseText` instead (the only fill in the kit); the v2
31//     `● text` chip on the terminal;
32//   - header(label, right?): an upper-cased `inactive` section label with a right-hand count;
33//   - icon(name, tone?), typeBadge(fileName) and progress(ratio, opts?): Svg icons, file-type badges
34//     and a rounded bar; a glyph, a bold label and the v2 bar on the terminal;
35//   - buttons() items take an optional `variant` ('primary' | 'secondary');
36//   - nav(items, currentKey): a pane's nav bar, the current item bold Text, the others plain Buttons;
37//   - pure helpers: pillSvg, shortPath and extOf; TONE_HEX, the Svg colors of icons and bars, and
38//     CHIP_HEX, the darker pill fills that keep white text at 4.5:1 or more.
39// Svg markup cannot read theme keys, so it uses fixed mid-tone hexes that read on light and dark
40// grounds (never a color word), and each distinct drawing is built once and cached.
41//
42// What v3.1 adds (spacing; additive): on the desktop, blocks stacked with no gap touch border to
43// border and wrapped rows sit line on line. So every wrapping row (buttons, tiles, nav) gets rowGap 1,
44// buttons get columnGap 2, and two new columns space a pane out:
45//   - page(children): a pane's top-level column, one row between blocks;
46//   - list(children): a column of repeated cards or blocks, one row between them.
47// Rows inside one card or block stay tight (stack with no gap); stack() keeps its props. Bands that
48// draw buttons() are one row, so the new gaps change nothing there. buttons() items also take
49// `autoFocus` (passed only when true).
50
51export const KIT_VERSION = '3.1.0'
52
53/** How pill() draws on a surface with Svg: 'svg' (a fully rounded chip) or 'fill' (a theme-key
54 *  background). makeKit's `pill` option overrides it for one kit. */
55export const PILL: 'fill' | 'svg' = 'svg'
56
57/** kit v3: makeKit's options. `pill` overrides PILL for this kit. */
58export type KitOptions = { pill?: 'fill' | 'svg' }
59
60/** 'theme': theme keys that follow the person's light or dark theme. 'named': named colors, for a
61 *  host that does not paint theme keys (never yellow: warn is red plus its glyph). One-line switch. */
62export const PALETTE: 'theme' | 'named' = 'theme'
63
64export type Surface = 'terminal' | 'desktop' | (string & {})
65export type Tone = 'info' | 'ok' | 'warn' | 'danger' | 'muted' | 'accent'
66export type Role = 'main' | 'recon' | 'execute' | 'verify' | 'hard' | 'reviewer' | 'other'
67export type DoodleState = 'running' | 'done' | 'failed' | 'waiting' | 'idle'
68/** The tones of pill() and icon(): the six tones plus violet. */
69export type PillTone = Tone | 'violet'
70export type IconName =
71  | 'eye'
72  | 'pencil'
73  | 'plus'
74  | 'branch'
75  | 'search'
76  | 'folder'
77  | 'folder-open'
78  | 'file'
79  | 'shield'
80  | 'flag'
81  | 'check'
82  | 'dot'
83  | 'clock'
84/** What `$.ui.resolve(e)` returns, loosely typed so the kit compiles without the engine's types. */
85export type Elements = Record<string, (props: Record<string, unknown>) => unknown>
86
87export type Kit = {
88  hero(p: { kicker?: string; title: string; sub?: string; icon?: string; tone?: Tone }): unknown
89  card(p: { title?: string; right?: string; tone?: Tone; children: unknown[] }): unknown
90  section(label: string, right?: string): unknown
91  tiles(items: { label: string; value: string; tone?: Tone }[]): unknown
92  badge(text: string, tone?: Tone): unknown
93  legend(items: { tone: Tone; label: string }[]): unknown
94  bar(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
95  /** One row, left side truncating, right side muted. `align: 'center'` (kit v3) centers a row holding a pill. */
96  row(p: { left: unknown[] | string; right?: unknown[] | string; key?: string; align?: 'center' }): unknown
97  kv(label: string, value: string, tone?: Tone): unknown
98  buttons(
99    items: {
100      key: string
101      label: string
102      hotkey?: string
103      onPress: (e?: unknown) => unknown
104      plain?: boolean
105      /** kit v3: the Button's variant, passed only when set. */
106      variant?: 'primary' | 'secondary'
107      /** kit 3.1: focus this Button when the pane opens; passed only when true. */
108      autoFocus?: boolean
109    }[],
110  ): unknown
111  doodle(role: Role, state?: DoodleState): unknown
112  stack(children: unknown[], gap?: number): unknown
113  /** kit 3.1: a pane's top-level column, one row between its blocks; null children are dropped. */
114  page(children: unknown[]): unknown
115  /** kit 3.1: a column of repeated cards or blocks, one row between them; null children are dropped. */
116  list(children: unknown[]): unknown
117  text(s: string, props?: Record<string, unknown>): unknown
118  /**
119   * One band line: `label  ● a  ● b      right · text`. A chip is `{ text, tone? }`, a string (a muted
120   * chip) or an element such as badge(); at most four are drawn. `right` is a string or a list
121   * joined with ` · `, drawn `inactive` and truncated. One row high.
122   */
123  line(label: string, chips: unknown[], right?: string | string[]): unknown
124  /** kit v3: a rounded chip (Svg surfaces) or the v2 `● text` chip (terminal). */
125  pill(text: string, tone?: Tone | 'violet'): unknown
126  /** kit v3: one row, the label upper-cased, bold and `inactive`; `right` upper-cased, right-aligned. */
127  header(label: string, right?: string): unknown
128  /** kit v3: a 16×16 stroke icon (Svg surfaces) or one glyph in the tone (terminal). */
129  icon(name: IconName, tone?: Tone | 'violet'): unknown
130  /** kit v3: a file-type badge ('JS', 'PDF', …) for a file name; null when the name has no extension. */
131  typeBadge(fileName: string): unknown
132  /** kit v3: a rounded bar (Svg surfaces; `width` in CSS pixels, absent: the slot's width) or bar(). */
133  progress(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
134  /**
135   * kit v3: a pane's nav bar, one row keyed 'nav': the item whose key is `currentKey` as bold Text in the accent
136   * tone (not pressable), the others plain Buttons with their hotkeys (`1: Stage`); one without onPress quiet Text.
137   */
138  nav(items: { key: string; label: string; hotkey?: string; onPress?: () => unknown }[], currentKey: string): unknown
139  surface: Surface
140}
141
142/** The quiet color: secondary text, labels, units, timestamps, frame and tile borders. */
143const QUIET = 'inactive'
144
145const THEME_TONES: Record<Tone, string> = {
146  info: 'suggestion',
147  ok: 'success',
148  warn: 'warning',
149  danger: 'error',
150  muted: QUIET,
151  accent: 'claude',
152}
153
154const NAMED_TONES: Record<Tone, string> = {
155  info: 'blue',
156  ok: 'green',
157  warn: 'red',
158  danger: 'red',
159  muted: QUIET,
160  accent: 'magenta',
161}
162
163const TONE_COLORS = PALETTE === 'named' ? NAMED_TONES : THEME_TONES
164
165/** fg: the color of the tone, a theme key (a named color when PALETTE is 'named'). bg equals fg: v2
166 *  draws no fills; the field stays so v1 callers compile. */
167export const TONES: Record<Tone, { fg: string; bg: string }> = {
168  info: { fg: TONE_COLORS.info, bg: TONE_COLORS.info },
169  ok: { fg: TONE_COLORS.ok, bg: TONE_COLORS.ok },
170  warn: { fg: TONE_COLORS.warn, bg: TONE_COLORS.warn },
171  danger: { fg: TONE_COLORS.danger, bg: TONE_COLORS.danger },
172  muted: { fg: TONE_COLORS.muted, bg: TONE_COLORS.muted },
173  accent: { fg: TONE_COLORS.accent, bg: TONE_COLORS.accent },
174}
175
176/**
177 * kit v3: the Svg colors of icons and bars, a mid-tone hex per tone that reads on light and dark grounds
178 * (Svg markup cannot read theme keys). warn and accent share the Claude orange: the theme's warning
179 * color is unreadable as a fill. A pill fills with CHIP_HEX instead.
180 *
181 *   info #3B82F6   ok #22A35A   warn #D97757   danger #E5484D   accent #D97757   muted #8A8F98
182 *   violet #8B5CF6
183 */
184export const TONE_HEX: Record<PillTone, string> = {
185  info: '#3B82F6',
186  ok: '#22A35A',
187  warn: '#D97757',
188  danger: '#E5484D',
189  accent: '#D97757',
190  muted: '#8A8F98',
191  violet: '#8B5CF6',
192}
193
194/**
195 * kit v3: a pill's fill per tone, darker than TONE_HEX so its white text has a contrast ratio of at
196 * least 4.5:1 (WCAG AA for small text). The ratios against white:
197 *
198 *   info #2563EB 5.17   ok #15803D 5.02   warn #C2410C 5.18   danger #B91C1C 6.47   accent #C2410C 5.18
199 *   muted #5B6170 6.20   violet #7C3AED 5.70
200 */
201export const CHIP_HEX: Record<PillTone, string> = {
202  info: '#2563EB',
203  ok: '#15803D',
204  warn: '#C2410C',
205  danger: '#B91C1C',
206  accent: '#C2410C',
207  muted: '#5B6170',
208  violet: '#7C3AED',
209}
210
211/** kit v3: the theme key a pill fills with, per tone (warn is 'claude': a 'warning' fill is unreadable). */
212const PILL_FILLS: Record<PillTone, string> = {
213  info: 'suggestion',
214  ok: 'success',
215  warn: 'claude',
216  danger: 'error',
217  accent: 'claude',
218  muted: QUIET,
219  violet: 'permission',
220}
221
222/** kit v3: the stroke of an icon drawn without a tone. */
223const ICON_STROKE = '#6B7280'
224/** kit v3: the track of progress(): the muted hex at quarter opacity. */
225const TRACK_HEX = '#8A8F98'
226/** kit v3: the text on a light fill (the amber JS badge); light text everywhere else. */
227const INK_DARK = '#1A1A1A'
228const INK_LIGHT = '#FFFFFF'
229
230/** kit v3: icon markup on a 16×16 grid, stroked with `currentColor`'s stand-in `S`. */
231const ICONS: Record<IconName, string> = {
232  eye: '<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>',
233  pencil: '<path d="M11 2.5 13.5 5 5.5 13H3v-2.5z"/><path d="M9.5 4 12 6.5"/>',
234  plus: '<path d="M8 3v10M3 8h10"/>',
235  branch:
236    '<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5" r="1.5"/>' +
237    '<path d="M4.5 5v6M11.5 6.5c0 2.5-2 3.5-4 3.8-1.6.2-3 .7-3 .7"/>',
238  search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
239  folder: '<path d="M1.75 3.25h4.5l1.5 1.75h6.5v7.75H1.75z"/>',
240  'folder-open': '<path d="M1.75 12.75v-9.5h4.5l1.5 1.75h5v2"/><path d="M1.75 12.75 3.75 7h10.5l-2 5.75z"/>',
241  file: '<path d="M9 1.75H3v12.5h10v-8.5z"/><path d="M9 1.75v4h4"/>',
242  shield: '<path d="M8 1.75 13 3.75v4c0 3.2-2.2 5.4-5 6.5-2.8-1.1-5-3.3-5-6.5v-4z"/>',
243  flag: '<path d="M3 14.5V2"/><path d="M3 2.5h8.5L9.75 5.5l1.75 3H3"/>',
244  check: '<path d="M3 8.5 6 11.5 13 4.5"/>',
245  dot: '<circle cx="8" cy="8" r="3" fill="S"/>',
246  clock: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
247}
248
249/** kit v3: the terminal glyph of each icon. */
250const ICON_GLYPHS: Record<IconName, string> = {
251  eye: '◉',
252  pencil: '✎',
253  plus: '+',
254  branch: '⎇',
255  search: '⌕',
256  folder: '▸',
257  'folder-open': '▾',
258  file: '·',
259  shield: '⛨',
260  flag: '⚑',
261  check: '✓',
262  dot: '●',
263  clock: '◷',
264}
265
266/** kit v3: file-type badges by extension: the label, its Svg fill and its terminal tone. */
267const FILE_TYPES: { exts: string[]; label: string; fill: string; tone: Tone }[] = [
268  { exts: ['js', 'jsx', 'mjs', 'cjs'], label: 'JS', fill: '#F0C419', tone: 'accent' },
269  { exts: ['ts', 'tsx'], label: 'TS', fill: '#3178C6', tone: 'info' },
270  { exts: ['pdf'], label: 'PDF', fill: '#E5484D', tone: 'danger' },
271  { exts: ['md', 'mdx'], label: 'MD', fill: '#4A7BD0', tone: 'info' },
272  { exts: ['doc', 'docx'], label: 'DOC', fill: '#2B579A', tone: 'info' },
273  { exts: ['csv'], label: 'CSV', fill: '#22A35A', tone: 'ok' },
274  { exts: ['json'], label: 'JSON', fill: '#64748B', tone: 'muted' },
275  { exts: ['yml', 'yaml'], label: 'YML', fill: '#64748B', tone: 'muted' },
276  { exts: ['html'], label: 'HTML', fill: '#E8743B', tone: 'accent' },
277  { exts: ['css'], label: 'CSS', fill: '#4A7BD0', tone: 'info' },
278  { exts: ['py'], label: 'PY', fill: '#3776AB', tone: 'info' },
279  { exts: ['sh'], label: 'SH', fill: '#64748B', tone: 'muted' },
280  { exts: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'], label: 'IMG', fill: '#8B5CF6', tone: 'accent' },
281  { exts: ['txt'], label: 'TXT', fill: '#64748B', tone: 'muted' },
282]
283const OTHER_FILL = '#64748B'
284
285const GLYPHS: Record<Role, string> = {
286  main: '◉',
287  recon: '◈',
288  execute: '▣',
289  verify: '✓',
290  hard: '◆',
291  reviewer: '◎',
292  other: '●',
293}
294
295const STATE_TONES: Record<DoodleState, Tone> = {
296  running: 'info',
297  done: 'ok',
298  failed: 'danger',
299  waiting: 'warn',
300  idle: 'muted',
301}
302
303const STATE_COLORS: Record<DoodleState, string> = {
304  running: TONES[STATE_TONES.running].fg,
305  done: TONES[STATE_TONES.done].fg,
306  failed: TONES[STATE_TONES.failed].fg,
307  waiting: TONES[STATE_TONES.waiting].fg,
308  idle: TONES[STATE_TONES.idle].fg,
309}
310
311// Desktop doodles (svg fills are not text colors, so they stay hex): 12×12 cells, 3 px each. B = body (by role), A = accent (by state), . = empty.
312const BODY_FILL: Record<Role, string> = {
313  main: '#6c63ff',
314  recon: '#1f9eaa',
315  execute: '#3b7ddd',
316  verify: '#2f9e55',
317  hard: '#b5523b',
318  reviewer: '#c08a1e',
319  other: '#7d8590',
320}
321
322const ACCENT_FILL: Record<DoodleState, string> = {
323  running: '#22d3ee',
324  done: '#4ade80',
325  failed: '#f87171',
326  waiting: '#facc15',
327  idle: '#e5e7eb',
328}
329
330const SPRITES: Record<Role, string[]> = {
331  // a crowned blob
332  main: [
333    '............',
334    '..A..AA..A..',
335    '..AAAAAAAA..',
336    '..BBBBBBBB..',
337    '.BBBBBBBBBB.',
338    '.BBAABBAABB.',
339    '.BBAABBAABB.',
340    '.BBBBBBBBBB.',
341    '.BBBAAAABBB.',
342    '.BBBBBBBBBB.',
343    '..BB....BB..',
344    '..BB....BB..',
345  ],
346  // a one-eyed scout drone
347  recon: [
348    '....BBBB....',
349    '..BBBBBBBB..',
350    '.BBBBBBBBBB.',
351    'BBBBAAAABBBB',
352    'BBBAAAAAABBB',
353    'BBBAA..AABBB',
354    'BBBAAAAAABBB',
355    'BBBBAAAABBBB',
356    '.BBBBBBBBBB.',
357    '..BBBBBBBB..',
358    '...B.BB.B...',
359    '..B..BB..B..',
360  ],
361  // a boxy robot with an antenna
362  execute: [
363    '.....AA.....',
364    '.....BB.....',
365    '.BBBBBBBBBB.',
366    '.BBBBBBBBBB.',
367    '.BBAABBAABB.',
368    '.BBAABBAABB.',
369    '.BBBBBBBBBB.',
370    '.BBAAAAAABB.',
371    '.BBBBBBBBBB.',
372    '...BBBBBB...',
373    '..BBB..BBB..',
374    '..BB....BB..',
375  ],
376  // a shield with a check mark
377  verify: [
378    '.BBBBBBBBBB.',
379    'BBBBBBBBBBBB',
380    'BBBBBBBBBBBB',
381    'BBBBBBBBBAAB',
382    'BBBBBBBBAABB',
383    'BAABBBBAABBB',
384    'BBAABBAABBBB',
385    '.BBAAAABBBB.',
386    '.BBBAABBBBB.',
387    '..BBBBBBBB..',
388    '...BBBBBB...',
389    '.....BB.....',
390  ],
391  // a gem golem
392  hard: [
393    '.....BB.....',
394    '....BBBB....',
395    '...BBBBBB...',
396    '..BBBBBBBB..',
397    '.BBAABBAABB.',
398    'BBBAABBAABBB',
399    'BBBBBBBBBBBB',
400    '.BBBBAABBBB.',
401    '..BBBBBBBB..',
402    '...BBBBBB...',
403    '....BBBB....',
404    '.....BB.....',
405  ],
406  // a round face with glasses
407  reviewer: [
408    '...BBBBBB...',
409    '..BBBBBBBB..',
410    '.BBBBBBBBBB.',
411    'BBBBBBBBBBBB',
412    'BAAAABBAAAAB',
413    'BA..AAAA..AB',
414    'BAAAABBAAAAB',
415    'BBBBBBBBBBBB',
416    'BBBBAAAABBBB',
417    '.BBBBBBBBBB.',
418    '..BBBBBBBB..',
419    '...BBBBBB...',
420  ],
421  // a ghost
422  other: [
423    '....BBBB....',
424    '..BBBBBBBB..',
425    '.BBBBBBBBBB.',
426    '.BBBBBBBBBB.',
427    'BBBAABBAABBB',
428    'BBBAABBAABBB',
429    'BBBBBBBBBBBB',
430    'BBBBBBBBBBBB',
431    'BBBBBBBBBBBB',
432    'BBBBBBBBBBBB',
433    'BBB.BBBB.BBB',
434    'B....BB....B',
435  ],
436}
437
438const CELL = 3
439const GRID = 12
440
441// ---------------------------------------------------------------- formatters
442
443/** '≈$0.08', '≈$1.2', '≈$12'; '$0' for zero, '<$0.01' below a cent, '—' when unknown. */
444export function money(usd: number | null | undefined): string {
445  if (typeof usd !== 'number' || !Number.isFinite(usd)) return '—'
446  const v = Math.max(0, usd)
447  if (v === 0) return '$0'
448  if (v < 0.005) return '<$0.01'
449  const cents = Math.round(v * 100) / 100
450  if (cents < 1) return '≈$' + cents.toFixed(2)
451  const dimes = Math.round(v * 10) / 10
452  if (dimes < 10) return '≈$' + dimes.toFixed(1)
453  return '≈$' + String(Math.round(v))
454}
455
456/** '0', '950', '1.5k', '37k', '1.2M', '15M'. */
457export function tokensShort(n: number): string {
458  if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '0'
459  if (n < 1000) return String(Math.round(n))
460  const k = n / 1000
461  if (k < 10) {
462    const r = Math.round(k * 10) / 10
463    if (r < 10) return trimZero(r.toFixed(1)) + 'k'
464  }
465  if (Math.round(k) < 1000) return String(Math.round(k)) + 'k'
466  const m = n / 1_000_000
467  const r = Math.round(m * 10) / 10
468  if (r < 10) return trimZero(r.toFixed(1)) + 'M'
469  return String(Math.round(m)) + 'M'
470}
471
472/** '0:40', '10:05', '1:02:03'. */
473export function clock(ms: number): string {
474  const total = typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
475  const h = Math.floor(total / 3600)
476  const m = Math.floor((total % 3600) / 60)
477  const s = total % 60
478  if (h > 0) return `${h}:${pad2(m)}:${pad2(s)}`
479  return `${m}:${pad2(s)}`
480}
481
482/** '4%', '100%'. Not capped at 100 (a budget can run over); negative and NaN read 0%. */
483export function pct(ratio: number): string {
484  if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio <= 0) return '0%'
485  return String(Math.round(ratio * 100)) + '%'
486}
487
488/** '━━━━╌╌╌╌╌╌' for 0.4 at width 10; the ratio is clamped to 0..1. */
489export function barText(ratio: number, width: number = 10): string {
490  const w = typeof width === 'number' && Number.isFinite(width) && width >= 1 ? Math.floor(width) : 10
491  const filled = Math.round(clamp01(ratio) * w)
492  return '━'.repeat(filled) + '╌'.repeat(w - filled)
493}
494
495function clamp01(ratio: number): number {
496  if (typeof ratio !== 'number' || !Number.isFinite(ratio)) return 0
497  return Math.min(1, Math.max(0, ratio))
498}
499
500function pad2(n: number): string {
501  return n < 10 ? '0' + n : String(n)
502}
503
504function trimZero(s: string): string {
505  return s.endsWith('.0') ? s.slice(0, -2) : s
506}
507
508function toneOf(tone: unknown, fallback: Tone): Tone {
509  return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONES, tone) ? (tone as Tone) : fallback
510}
511
512function roleOf(role: unknown): Role {
513  return typeof role === 'string' && Object.prototype.hasOwnProperty.call(GLYPHS, role) ? (role as Role) : 'other'
514}
515
516function stateOf(state: unknown): DoodleState {
517  return typeof state === 'string' && Object.prototype.hasOwnProperty.call(STATE_COLORS, state)
518    ? (state as DoodleState)
519    : 'idle'
520}
521
522/** Drops undefined and false props: some props (Button plain, autoFocus) take only `true`. */
523function clean(props: Record<string, unknown>): Record<string, unknown> {
524  const out: Record<string, unknown> = {}
525  for (const k of Object.keys(props)) {
526    const v = props[k]
527    if (v === undefined || v === false) continue
528    out[k] = v
529  }
530  return out
531}
532
533function present(children: unknown[]): unknown[] {
534  return children.filter(c => c !== null && c !== undefined && c !== false && c !== '')
535}
536
537/** The SVG document of one doodle: a 12×12 sprite at 3 px per cell, runs merged per row. */
538export function doodleSvg(role: Role, state: DoodleState = 'idle'): string {
539  const r = roleOf(role)
540  const s = stateOf(state)
541  const fills: Record<string, string> = { B: BODY_FILL[r], A: ACCENT_FILL[s] }
542  const size = GRID * CELL
543  let rects = ''
544  SPRITES[r].forEach((line, y) => {
545    let x = 0
546    while (x < GRID) {
547      const ch = line[x] ?? '.'
548      let end = x + 1
549      while (end < GRID && line[end] === ch) end++
550      const fill = fills[ch]
551      if (fill) {
552        rects += `<rect x="${x * CELL}" y="${y * CELL}" width="${(end - x) * CELL}" height="${CELL}" fill="${fill}"/>`
553      }
554      x = end
555    }
556  })
557  return (
558    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" ` +
559    `shape-rendering="crispEdges">${rects}</svg>`
560  )
561}
562
563/** The terminal glyph of a role, as doodle() draws it and as the desktop doodle's alt text. */
564export function glyph(role: Role): string {
565  return GLYPHS[roleOf(role)]
566}
567
568// ---------------------------------------------------------------- kit v3: pure helpers and Svg
569
570/** 'ts' for 'src/a.test.TS', '' for 'Makefile', '.gitignore' or 'notes.'. Lower case, no dot. */
571export function extOf(name: string): string {
572  const base = String(name ?? '').split(/[\\/]/).pop() ?? ''
573  const dot = base.lastIndexOf('.')
574  return dot > 0 && dot < base.length - 1 ? base.slice(dot + 1).toLowerCase() : ''
575}
576
577/**
578 * A path for display: inside `root` it is relative ('.' for the root itself), inside `home` it is
579 * '~/' and the rest, anywhere else '…/' and its last two segments, so no absolute path from outside
580 * the project is drawn. A relative path is already short and comes back as given. Trailing slashes
581 * are dropped.
582 */
583export function shortPath(path: string, root: string, home: string): string {
584  const p = trimSlash(path)
585  const r = trimSlash(root)
586  const h = trimSlash(home)
587  if (!p.startsWith('/')) return p
588  if (r && p === r) return '.'
589  if (r && r !== '/' && p.startsWith(r + '/')) return p.slice(r.length + 1)
590  if (h && p === h) return '~'
591  if (h && h !== '/' && p.startsWith(h + '/')) return '~/' + p.slice(h.length + 1)
592  return '…/' + p.split('/').filter(Boolean).slice(-2).join('/')
593}
594
595function trimSlash(s: string): string {
596  const t = String(s ?? '')
597  return t.length > 1 ? t.replace(/\/+$/, '') || '/' : t
598}
599
600const SVG_CACHE = new Map<string, string>()
601const SVG_CACHE_MAX = 512
602
603/** Builds each distinct drawing once; the cache is cleared when it grows past its bound. */
604function cached(key: string, build: () => string): string {
605  const hit = SVG_CACHE.get(key)
606  if (hit !== undefined) return hit
607  if (SVG_CACHE.size >= SVG_CACHE_MAX) SVG_CACHE.clear()
608  const svg = build()
609  SVG_CACHE.set(key, svg)
610  return svg
611}
612
613function xml(s: string): string {
614  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
615}
616
617/** Dark text on a light fill (relative luminance above one half), light text otherwise. */
618function inkOn(hex: string): string {
619  const n = parseInt(hex.slice(1), 16)
620  const lin = (c: number) => {
621    const v = c / 255
622    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
623  }
624  const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255)
625  return l > 0.5 ? INK_DARK : INK_LIGHT
626}
627
628function pillToneOf(tone: unknown, fallback: PillTone): PillTone {
629  return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONE_HEX, tone) ? (tone as PillTone) : fallback
630}
631
632function iconOf(name: unknown): IconName {
633  return typeof name === 'string' && Object.prototype.hasOwnProperty.call(ICONS, name) ? (name as IconName) : 'dot'
634}
635
636const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
637
638/** A label in a rounded rect: the shape of a pill (height 18) and of a type badge (height 16). */
639function labelSvg(text: string, fill: string, width: number, height: number, rx: number, size: number, ink = inkOn(fill)): string {
640  return (
641    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
642    `<rect width="${width}" height="${height}" rx="${rx}" fill="${fill}"/>` +
643    `<text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="central" ${FONT} ` +
644    `font-size="${size}" font-weight="700" fill="${ink}">${xml(text)}</text></svg>`
645  )
646}
647
648/** A pill's height in CSS pixels; its corners are fully rounded (rx = height / 2). */
649const PILL_HEIGHT = 18
650
651/** A pill's width in CSS pixels: about 6.5 per character of 11 px bold text plus 7 each side. */
652function pillWidth(text: string): number {
653  return Math.max(PILL_HEIGHT, Math.round(text.length * 6.5 + 14))
654}
655
656/** The Svg of a pill: a fully rounded rect in the tone's CHIP_HEX fill, white text. */
657export function pillSvg(text: string, tone: Tone | 'violet' = 'muted'): string {
658  const t = pillToneOf(tone, 'muted')
659  const s = String(text)
660  return cached(`pill|${t}|${s}`, () => labelSvg(s, CHIP_HEX[t], pillWidth(s), PILL_HEIGHT, PILL_HEIGHT / 2, 11, INK_LIGHT))
661}
662
663function iconSvg(name: IconName, hex: string): string {
664  return cached(`icon|${name}|${hex}`, () =>
665    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" ` +
666    `stroke="${hex}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">` +
667    `${ICONS[name].replace(/"S"/g, `"${hex}"`)}</svg>`,
668  )
669}
670
671function fileType(fileName: string): { label: string; fill: string; tone: Tone } | null {
672  const ext = extOf(fileName)
673  if (!ext) return null
674  const known = FILE_TYPES.find(t => t.exts.includes(ext))
675  return known ?? { label: ext.slice(0, 3).toUpperCase(), fill: OTHER_FILL, tone: 'muted' }
676}
677
678/** A type badge is 16 high and about 7 px per letter plus 8 wide. */
679function badgeWidth(label: string): number {
680  return Math.max(16, label.length * 7 + 8)
681}
682
683function badgeSvg(label: string, fill: string): string {
684  return cached(`badge|${label}|${fill}`, () => labelSvg(label, fill, badgeWidth(label), 16, 3, 10))
685}
686
687/** A track and a fill, 6 high. Without a width the markup is 100% wide and stretches to the slot. */
688function progressSvg(ratio: number, hex: string, width: number | undefined): string {
689  const full = width ?? 100
690  const fill = Math.round(clamp01(ratio) * full * 10) / 10
691  return cached(`progress|${fill}|${hex}|${width ?? ''}`, () => {
692    const size = width === undefined ? 'width="100%" height="6" preserveAspectRatio="none"' : `width="${width}" height="6"`
693    return (
694      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${full} 6" ${size}>` +
695      `<rect width="${full}" height="6" rx="3" fill="${TRACK_HEX}" opacity="0.25"/>` +
696      (fill > 0 ? `<rect width="${fill}" height="6" rx="3" fill="${hex}"/>` : '') +
697      `</svg>`
698    )
699  })
700}
701
702// ---------------------------------------------------------------- the kit
703
704const MAX_CHIPS = 4
705
706export function makeKit(ui: Elements, surface: Surface, opts: KitOptions = {}): Kit {
707  const desktop = surface === 'desktop'
708  const pillMode = opts.pill ?? PILL
709
710  const el = (type: string, props: Record<string, unknown>, children?: unknown[]): unknown => {
711    const make = ui[type]
712    const p = clean(props)
713    if (children !== undefined) p.children = present(children)
714    return make(p)
715  }
716  const T = (s: string, props: Record<string, unknown> = {}): unknown => el('Text', props, [s])
717  const color = (tone: Tone | undefined): string | undefined => (tone ? TONES[tone].fg : undefined)
718  const pieces = (part: unknown[] | string, right: boolean): unknown[] =>
719    (typeof part === 'string' ? [part] : part).map(c =>
720      typeof c === 'string' || typeof c === 'number'
721        ? T(String(c), { color: right ? QUIET : undefined, wrap: 'truncate' })
722        : c,
723    )
724  const frame = (children: unknown[], tone: Tone | undefined): unknown =>
725    el('Box', { flexDirection: 'column', borderStyle: 'round', borderColor: color(tone) ?? QUIET, paddingX: 1 }, children)
726  const chip = (text: string, tone: Tone): unknown =>
727    T('● ' + text, { color: TONES[tone].fg, bold: tone !== 'muted', wrap: 'truncate' })
728  // kit v3: Svg wherever the table has it (desktop, mobile, vscode); the terminal draws text.
729  const svgOk = surface !== 'terminal' && typeof ui.Svg === 'function'
730  const svg = (source: string, alt: string, width?: number, height?: number): unknown =>
731    el('Svg', { source, alt: alt || ' ', width, height })
732  /** The theme key of a v3 tone on the terminal: the v2 tone's color, violet 'permission'. */
733  const fg = (tone: PillTone): string => (tone === 'violet' ? PILL_FILLS.violet : TONES[tone].fg)
734
735  const kit: Kit = {
736    surface,
737
738    hero(p) {
739      const tone = p.tone ? toneOf(p.tone, 'info') : undefined
740      const title = (p.icon ? p.icon + ' ' : '') + p.title
741      return frame(
742        [
743          p.kicker ? T(p.kicker, { color: QUIET, wrap: 'truncate' }) : null,
744          T(title, { bold: true, color: color(tone), wrap: 'truncate' }),
745          p.sub ? T(p.sub, { color: QUIET, wrap: 'wrap' }) : null,
746        ],
747        tone,
748      )
749    },
750
751    card(p) {
752      const tone = p.tone ? toneOf(p.tone, 'info') : undefined
753      const head =
754        p.title || p.right
755          ? el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
756              p.title ? T(p.title, { bold: true, wrap: 'truncate' }) : null,
757              p.right ? T(p.right, { color: QUIET, wrap: 'truncate' }) : null,
758            ])
759          : null
760      return frame([head, ...(Array.isArray(p.children) ? p.children : [])], tone)
761    },
762
763    section(label, right) {
764      return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
765        T(String(label), { bold: true, wrap: 'truncate' }),
766        right ? T(right, { color: QUIET, wrap: 'truncate' }) : null,
767      ])
768    },
769
770    tiles(items) {
771      return el(
772        'Box',
773        { flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 1 },
774        items.map(it => {
775          const tone = it.tone ? toneOf(it.tone, 'info') : undefined
776          return el(
777            'Box',
778            { flexDirection: 'column', flexGrow: 1, borderStyle: 'single', borderColor: QUIET, paddingX: 1 },
779            [
780              T(it.label, { color: QUIET, wrap: 'truncate' }),
781              T(it.value, { bold: true, color: color(tone), wrap: 'truncate' }),
782            ],
783          )
784        }),
785      )
786    },
787
788    badge(text, tone) {
789      return chip(text, toneOf(tone, 'muted'))
790    },
791
792    legend(items) {
793      const parts: unknown[] = []
794      items.forEach((it, i) => {
795        if (i > 0) parts.push('  ')
796        parts.push(T('●', { color: TONES[toneOf(it.tone, 'muted')].fg }))
797        parts.push(' ' + it.label)
798      })
799      return el('Text', { wrap: 'wrap' }, parts)
800    },
801
802    bar(ratio, opts = {}) {
803      const t = toneOf(opts.tone, 'info')
804      return el('Text', { wrap: 'truncate' }, [
805        T(barText(ratio, opts.width ?? 10), { color: TONES[t].fg }),
806        T(' ' + pct(ratio), { color: QUIET }),
807      ])
808    },
809
810    row(p) {
811      const left = pieces(p.left, false)
812      const right = p.right === undefined || p.right === '' ? [] : pieces(p.right, true)
813      const alignItems = p.align === 'center' ? 'center' : undefined
814      return el('Box', { key: p.key, flexDirection: 'row', justifyContent: 'space-between', alignItems, columnGap: 1 }, [
815        el('Box', { flexDirection: 'row', flexShrink: 1, alignItems, columnGap: 1 }, left),
816        right.length ? el('Box', { flexDirection: 'row', alignItems, columnGap: 1 }, right) : null,
817      ])
818    },
819
820    kv(label, value, tone) {
821      const t = tone ? toneOf(tone, 'info') : undefined
822      return el('Text', { wrap: 'truncate' }, [
823        T(label + ' ', { color: QUIET }),
824        T(value, { bold: true, color: color(t) }),
825      ])
826    },
827
828    buttons(items) {
829      return el(
830        'Box',
831        { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
832        items.map(b =>
833          el('Button', {
834            key: b.key,
835            label: b.label,
836            hotkey: b.hotkey,
837            onPress: b.onPress,
838            plain: b.plain === true ? true : undefined,
839            variant: b.variant === 'primary' || b.variant === 'secondary' ? b.variant : undefined,
840            autoFocus: b.autoFocus === true ? true : undefined,
841          }),
842        ),
843      )
844    },
845
846    doodle(role, state = 'idle') {
847      const r = roleOf(role)
848      const s = stateOf(state)
849      if (desktop && typeof ui.Svg === 'function') {
850        return el('Svg', { alt: GLYPHS[r], width: GRID * CELL, height: GRID * CELL, source: doodleSvg(r, s) })
851      }
852      return T(GLYPHS[r], { color: STATE_COLORS[s], bold: s === 'running' })
853    },
854
855    stack(children, gap = 0) {
856      return el('Box', { flexDirection: 'column', rowGap: gap > 0 ? Math.floor(gap) : undefined }, children)
857    },
858
859    page(children) {
860      return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
861    },
862
863    list(children) {
864      return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
865    },
866
867    text(s, props = {}) {
868      return el('Text', props, [String(s)])
869    },
870
871    line(label, chips, right) {
872      const drawn = (Array.isArray(chips) ? chips : [])
873        .filter(c => c !== null && c !== undefined && c !== false && c !== '')
874        .slice(0, MAX_CHIPS)
875        .map(c => {
876          if (typeof c === 'string') return chip(c, 'muted')
877          if (isChipSpec(c)) return chip(c.text, toneOf(c.tone, 'muted'))
878          return c
879        })
880      const quiet = (Array.isArray(right) ? right.filter(Boolean).join(' · ') : right ?? '').trim()
881      return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 2 }, [
882        el('Box', { flexDirection: 'row', flexShrink: 1, columnGap: 2 }, [
883          T(String(label), { bold: true, wrap: 'truncate' }),
884          ...drawn,
885        ]),
886        quiet ? T(quiet, { color: QUIET, wrap: 'truncate' }) : null,
887      ])
888    },
889
890    // ------------------------------------------------------------ kit v3
891
892    pill(text, tone) {
893      const t = pillToneOf(tone, 'muted')
894      const s = String(text)
895      if (!svgOk) return t === 'violet' ? T('● ' + s, { color: fg(t), bold: true, wrap: 'truncate' }) : chip(s, t)
896      if (pillMode === 'svg') return svg(pillSvg(s, t), s, pillWidth(s), PILL_HEIGHT)
897      return T(` ${s} `, { backgroundColor: PILL_FILLS[t], color: 'inverseText', bold: true, wrap: 'truncate' })
898    },
899
900    header(label, right) {
901      return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
902        T(String(label).toUpperCase(), { bold: true, color: QUIET, wrap: 'truncate' }),
903        right ? T(String(right).toUpperCase(), { color: QUIET, wrap: 'truncate' }) : null,
904      ])
905    },
906
907    icon(name, tone) {
908      const n = iconOf(name)
909      const t = tone === undefined ? undefined : pillToneOf(tone, 'muted')
910      if (svgOk) return svg(iconSvg(n, t ? TONE_HEX[t] : ICON_STROKE), n, 16, 16)
911      return T(ICON_GLYPHS[n], { color: t ? fg(t) : QUIET })
912    },
913
914    typeBadge(fileName) {
915      const type = fileType(fileName)
916      if (!type) return null
917      if (svgOk) {
918        return svg(badgeSvg(type.label, type.fill), type.label, badgeWidth(type.label), 16)
919      }
920      return T(type.label, { bold: true, color: TONES[type.tone].fg })
921    },
922
923    progress(ratio, opts = {}) {
924      if (!svgOk) return kit.bar(ratio, opts)
925      const t = toneOf(opts.tone, 'info')
926      const width = typeof opts.width === 'number' && Number.isFinite(opts.width) && opts.width >= 1 ? Math.floor(opts.width) : undefined
927      // Without a width the Svg gets no width prop and markup 100% wide: the user's look confirms the
928      // desktop draws it the full width of its slot.
929      return svg(progressSvg(ratio, TONE_HEX[t], width), pct(ratio), width, 6)
930    },
931
932    nav(items, currentKey) {
933      return el(
934        'Box',
935        { key: 'nav', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
936        (Array.isArray(items) ? items : []).map(it => {
937          if (it.key === currentKey) return T(String(it.label), { bold: true, color: TONES.accent.fg, wrap: 'truncate' })
938          if (typeof it.onPress !== 'function') return T(String(it.label), { color: QUIET, wrap: 'truncate' })
939          return el('Button', { key: it.key, label: String(it.label), hotkey: it.hotkey, onPress: it.onPress, plain: true })
940        }),
941      )
942    },
943  }
944  return kit
945}
946
947function isChipSpec(c: unknown): c is { text: string; tone?: Tone } {
948  return (
949    typeof c === 'object' &&
950    c !== null &&
951    !('type' in c) &&
952    typeof (c as { text?: unknown }).text === 'string'
953  )
954}
955
types/index.d.ts 83 lines
1export type Mode = 'ask' | 'auto' | 'off'
2
3/** The merged settings: defaults, ~/.claude/handoff-guard.json, <cwd>/.claude/handoff-guard.json, then /handoff changes in $.store. */
4export type Config = {
5  /** Context fraction where the band turns yellow and one toast shows, in (0, 1]. */
6  warnAt: number
7  /** Context fraction where the handoff is due (ask, auto or just a red band). */
8  handoffAt: number
9  /** Where "Later" asks again. */
10  reaskAt: number
11  /** In auto mode, new tool calls are refused from here until the handoff clears the session. */
12  denyAt: number
13  mode: Mode
14  /** Model alias for the brief when $.model.fork is unavailable. */
15  model: string
16  /** maxTokens for the $.model.complete fallback; also caps the words asked for. */
17  maxBriefTokens: number
18  /** Draw the band above the prompt (/handoff band on|off). */
19  band: boolean
20}
21
22/** One context measurement. */
23export type Reading = {
24  tokens: number | null
25  window: number | null
26  /** Fill as a fraction of the window, 0..1. */
27  frac: number
28  /** $.clock.now() when it was taken. */
29  at: number
30  /** start, step, complete or compact. */
31  where: string
32}
33
34/** The last handoff written, kept in $.state and mirrored to $.store per project. */
35export type BriefInfo = {
36  at: number
37  /** Local "YYYY-MM-DD HH:MM". */
38  when: string
39  /** Absolute path of project.md. */
40  path: string
41  sessionId: string
42  /** fork, complete, or facts (the model reply was unusable). */
43  source: string
44  /** Figures in the model text that the facts do not hold. */
45  flagged: string[]
46  /** The brief as written, capped for display. */
47  preview: string
48}
49
50declare module 'claude-code' {
51  interface PluginState {
52    'lk-handoff-guard': {
53      config: Config
54      reading: Reading | null
55      /** The last 30 readings, oldest first. */
56      history: Reading[]
57      /** The warnAt toast has shown this session. */
58      warned: boolean
59      /** The next fill that asks in ask mode; 0 means handoffAt. */
60      nextAskAt: number
61      /** No more asks this session (Later at the re-ask level). */
62      askedOut: boolean
63      /** "Off this session" was picked. */
64      sessionOff: boolean
65      /** Auto mode has tried once this session. */
66      autoTried: boolean
67      /** A handoff was written since the last /clear (or compaction). */
68      written: boolean
69      /** A /clear is queued after a handoff. */
70      clearing: boolean
71      /** A handoff is being written. */
72      busy: boolean
73      /** Files changed by Edit/Write/MultiEdit/NotebookEdit this session and this turn. */
74      editedSession: string[]
75      editedTurn: string[]
76      lastBrief: BriefInfo | null
77      /** "written <when>" while this session was seeded from a handoff and the user has not typed yet. */
78      resumed: string
79      cwd: string
80    }
81  }
82}
83