SLOPSHOPPER

katharsis

An output style that classifies each message by exchange type and shapes the reply to fit it: the answer first, a ceiling per type, reference codes that…

newpanebandrowscommandprompt
★ 4v0.9.2MITupdated 2026-10-09OpenScribbler/Katharsis
A shopper browsing a rack in a slop shop
README

Katharsis

A Claude Code output style that classifies each message you send and shapes the reply to fit it.

License: MIT OpenSSF Scorecard

A status check, an approval, a bug report, and a request for a diagnosis each want a different reply. Claude Code answers all four with the same shape: a paragraph of narration, the answer somewhere in the middle, and an offer at the end. Katharsis makes the model classify your message into one of 11 exchange types before it writes, read a guidance file for that type, and shape the reply to it: what opens the reply, what stays out, and how long it may run.

A two-turn exchange about a rounding rule, answered by Claude Opus 5 under Claude Code's default style, left, and under Katharsis, right

Same two messages, same model, same sandbox repo, recorded in Claude Code 2.1.283 and sped up. The pricing tests expect half-up rounding that finance never confirmed, and the user asks: "should we change the code or the tests?" The default reply runs 423 words, opens with "Neither, yet", and ends by offering two more tasks. The Katharsis reply runs 438 words and opens with its recommendation: "Change the code to half-up, and treat it as provisional until finance answers." The user then says "go with what you recommend". The default spends 449 words on the work and opens with "Done, but I need to correct something I told you." The Katharsis reply runs 87 words and opens with "All 10 tests pass."

To see the same exchange on other models: Claude Opus 5.5 · Claude Sonnet 5 · Claude Fable 5.1 · Claude Fable 5. Every default reply ends its first turn with an offer of more work, and one Katharsis reply does, on Sonnet 5. Across both turns, Katharsis is shorter on Opus 5.5, Fable 5.1, and Fable 5, where it runs 224 words against 540, and longer on Sonnet 5, 498 words against 349. Every reply is stored verbatim in demo/captures/, and demo/ has the sandbox and the steps to reproduce them.

What changes in your replies

  • The answer opens the reply. Every type's guidance puts the finding, the result, or the state on the first line, and the reasoning after it.
  • The reply is sized to the ask. A four-word status check gets a sentence and the one next step. A request for a diagnosis gets room to argue. Each type carries its own ceiling, and a reply that runs long because the subject felt rich is the failure the ceilings exist to stop.
  • Every item you might refer back to carries a code. Findings, risks, actions taken, and next actions each get a code such as F1 or NA2, numbered continuously through the session, so "do NA2" and "more on F3" are complete instructions. Coded lines sit under the topic they belong to, and each fact appears once.
  • The model acts instead of asking. It makes every call that is cheap to undo and reports the result. A reply ends with a question only when a wrong answer would be expensive or reach past your machine and the model cannot infer your answer, with the options inside the question and a recommendation.
  • The codes survive the session. A Stop hook records every coded item to a ledger on disk, and kref reads them back, so F3 still resolves after a context compaction or in the next session.

Install

/plugin marketplace add OpenScribbler/Katharsis
/plugin install katharsis@openscribbler

Then, in a Claude Code session:

/katharsis:setup

Setup does the one thing a plugin cannot do for itself. The style has the model run one script per turn, and in default permission mode that Bash call prompts on first use in every session, so setup adds one entry to permissions.allow in ~/.claude/settings.json:

Bash(~/.claude/katharsis/scripts/katharsis-exchange-style.sh:*)

It writes nothing else outside ~/.claude/katharsis-data/. It also checks that Claude Code is 2.1.287 or later, the first version that loads mods without a flag, and prints the fix when it is older. The same script runs from a terminal as ~/.claude/katharsis/scripts/setup.sh, and --dry-run prints the change without writing it.

Last, pick the style. Open /config, choose Output style, and pick one of the two:

StyleWhat it is
katharsis:KatharsisThe style alone. Claude Code's built-in software-engineering instructions are dropped, which is the default for any custom output style.
katharsis:Katharsis codingThe same style with those built-in instructions kept.

The two share one body, and a test holds them identical below the frontmatter. /config saves the choice to .claude/settings.local.json in the current project. Until you pick one, the per-turn and Stop hooks stay silent and write nothing. The session-start hook runs regardless: it makes the symlink, creates the data directory, and prints one line asking for setup until setup has run.

Setup ends by offering /katharsis:rules-check, which you can also run at any time. It reads every instruction file Claude Code loads for the current project, listed by scripts/instruction-files.sh, and reports the rules that repeat the style, the rules that contradict it, and a count of the rest. Each duplicate and conflict comes with a suggested edit, and the skill changes a file only after you approve that edit. A file that reaches every project, such as ~/.claude/CLAUDE.md, needs its own yes.

Requirements

Claude Code 2.1.287 or later, bash, and python3. Only the routing script and the session-start hook are plain bash. Setup, the Stop hooks, and the Bash hooks need python3, so without it setup fails and the ledger is not written. kref needs Node.js 22.18 or later.

Mods

Katharsis is a mod: a plugin whose hooks module, a TypeScript file, Claude Code calls when events happen. Claude Code loads mods with no flag from 2.1.287 on, and Katharsis depends on it: hooks/register.ts carries the per-turn reminder, reading the active style from the settings the engine runs under and telling an untyped turn from the prompt's origin. On a Claude Code that doesn't load mods, no reminder reaches the model and the Stop hooks stay idle; the Bash hooks that save a file a call replaced unread still run. The module also draws the drawer. From the third turn, and every 15 turns after, it asks the model in a forked call to name the session in a few words, and stores the name in the session record. claude plugin test . runs the module's tests.

How it works

  1. You send a message. The prompt hook reads which output style is active and, when it is Katharsis, prints the classify-then-read instruction into the model's context along with the next free code numbers from the ledger. Claude Code names the active style itself on every turn, and this instruction is what keeps the classification step from fading over a long session. When the model changes to one that takes a different note, and after a compaction, the hook also attaches a short note for Fable, Opus, or Sonnet from styles/models/, correcting the leans Anthropic's prompting guide names for that model. A note named for the version, such as opus-5-5.md, would win over the family's note; none ships yet. After a compaction, the hook also lists each owed item the ledger still has open (next actions, your moves, waits, blocks, and questions, the oldest 12), with its body and a question's options and recommendation, each shortened to 200 characters, so the resumed turn does not depend on the summary's account of what was owed. When your message answers a question, such as Q3 a or Q3 x, the hook records the answer, which closes the question in the drawer. At the standard or autonomous autonomy level, the hook adds one line naming the level.
  2. The model classifies the message with the cue table in the style, then runs scripts/katharsis-exchange-style.sh <type>. The script prints the guidance file for that type, so running it is the read, and stamps the type for the Stop hook. It never classifies; that judgment stays with the model. An unknown type exits non-zero and prints the valid set.
  3. The model writes the reply under that file's Shape, Ceiling, and Verification sections.
  4. The Stop hooks run. One checks the stamp and, when a turn skipped the classification step, appends one JSON line to telemetry/gate-misses.jsonl with no message text. The script prints an === END: <type> === line after the guidance, and a stamped turn whose output lacks it, because a | head cut it short, is counted there as truncated. The second parses every coded item out of the reply and writes it to ledger/<project>/<session>.jsonl, and holds the reply once when it gives a code a different claim than the one on file with no E line naming that code. The third reads the finished reply and holds it once when it opens by narrating the intended action and buries the finding. The fourth checks the reply's claims against the session's tool results: tests, a build, plugin validation, a linter, or CI said to pass when the last run failed, ran before a later code edit, or never ran, in the reply or in a ticked checklist line of a PR body or commit, unless that line shows the check failing; a change the reply itself says it verified with nothing run after the last edit; and a count whose only source is grep -I, grep -c, or rg without -uu, which skip files or count lines instead of matches. A command counts as a run only where it is the command, so rg pytest is not a test run. When no command the hook knows ran but some command's output reads like a check's result, the hook says nothing. A failed command with several steps counts against a check only when the check's output shows a failure or the check is the last step. When two different commands for the same check ended differently, only a claim about all of them, such as "the tests pass", is judged. A checklist line in the file --body-file or git commit -F names is read only when that file has not changed since the call returned, and --replay reads no such file. A line count is not flagged when you asked for lines. When the count came from grep -r or rg searching one literal word under a folder and piped to wc -l, the hook first recounts that word in every file under the folder, binary and hidden ones included. If its number differs and the files grep or rg skipped account for the whole difference, the line names those files. If its number matches the reply's, skipped files are not reported. Otherwise, and at a symlink or special file, or past 5,000 files, 32 MiB, or 3 seconds, you get only the line about what the command skips. --replay never recounts. Each one appends a record to detections/<session>.jsonl and shows you one Katharsis check: line. None of these holds the reply.

A Bash call that replaces a file no earlier call in the session named (>, tee, cp, mv, dd of=) is checked around the call by the same script, as a PreToolUse and PostToolUse hook, so this check runs without the hooks module and in sessions where Katharsis is not the active style. Before the call it copies the file, a regular file up to 256 KiB found through any symlinks in its path, to a folder under the system temp directory that only you can open; if that folder or its parent is a symlink, belongs to someone else, or is open to anyone else, the hook does nothing. A file the same command first moves or copies elsewhere is not copied. After the call, when lines of the old content are gone, it saves that copy under clobbered/<session>/, readable only by you, appends a record that counts the lost lines and names the saved copy, shows you one line with the cp command that restores it, and tells the model the same in the call's result. A file the call left larger than 1 MiB is not compared. Once clobbered/ holds 64 MiB, it saves no new copy and says so; it never deletes one. If the first reply after the call says nothing about the loss and the file does not match its saved copy, the fourth Stop hook holds that reply once for one appended line per file naming the loss and that command. No later reply is held for that replacement. A reply written in answer to any Stop hook's hold, this one's included, is skipped, and the check falls to the next reply of the same turn, if one follows. When the reply or the two text blocks before it name a file not yet restored and say it was not there before, the reply is not held, since that line would contradict them; you get one Katharsis check: line with the restore command instead.

No hook ever asks for a reply to be written again. A hold asks only for the lines that were missing. For a drifted code, that is a line saying the code stands as on file, the corrected claim with an E line, or the new item under a fresh code. For a buried opening, it is the finding on its own line. For a replaced file, it is one line naming the loss and the saved copy. The reply you already read stands and only the added lines are new. A rule with no such repair records the reply and lets it through. Every hook exits 0 on every path where it cannot help, so a hook that fails costs you a ledger row, never a turn.

The exchange types

TypeThe message looks likeCeiling
factual-question"is X shipped?", "where does Y live?", "do these two rules conflict?"150 words
status-and-resume"how's it going?", "let's continue", a handoff file, "773 merged"250
approval"1. a", "go ahead", "sounds good", "go ahead, but hold off on the second part"250
thinking-out-loud"let's discuss", "does that make sense?", "can we do X?"350
diagnosis"why does this happen?", "is this bad practice?", "what do you think?"500
redirect"do it this way instead", "stop hedging", "I deleted it on purpose"250
broken-report"this reply is messed up", "the hook didn't fire", "I got 7, not 5"250
work-request"update the changelog", "run the tests", "open a PR for both fixes"400
canned-reviewA script-sent review prompt naming a diff and a method300
harness-probe"answer in one line", "reply with only the token, or NONE"the named form
defaultThree or more types, a greeting, a pasted fragment250

Ceilings count everything you read, coded items included, because a coded line costs the same reading time as a sentence. The one exemption is an agenda: when your message lists items, every item on it gets a line. The styles/README.md has the shared rules, and each styles/<type>.md has that type's cues, shape, ambiguities, and worked examples.

Reference codes

Each code has one form:

F1 - **the claim** - the evidence, in the same sentence

F findings, A assumptions, R risks, C caveats, AT actions taken, V verified, NA next actions, B blocked, MV your move, W waiting, X excluded, S state, T-O trade-offs, E errata, Q questions. Numbers never restart within a session. The model may define a new code when none fits, and the ledger records it either way, because detection is by shape rather than by an allowlist.

kref

kref reads the ledger back. Inside Claude Code, bash mode runs it from the plugin's bin/, which Claude Code puts on PATH, and the model's whole reply to that turn is "Logged." Below, the ledger comes from a four-day session on this repo that reached F145 and Q85 across 225 coded items. The visible reply is a short demo turn in that session rather than one of its own replies. A chip recalls caveat C21 from an earlier reply, the band counts every code type, and the drawer searches and filters the whole ledger. Then kref F100 fetches a finding from two days earlier, and kref search symlink --short lists every item across the ledger that mentions symlinks.

A reply late in a long Katharsis session: hovering the C21 chip recalls an old caveat, the band shows 50 questions, the drawer searches and filters the whole ledger, and kref fetches F100 and then searches the ledger for symlink

! kref                  this session's items under headings such as Findings, each in full
! kref F3               one item
! kref F                every F item
! kref search keytab    every item whose title, body, or options mention "keytab"
! kref sessions         the sessions that ran in this folder or below it, newest first
! kref --short NA       titles only
! kref --chrono         every item in the order it was written, rather than by heading
! kref --html           the same result as an HTML page
! kref --json           the same result as one JSON document, for scripts

In full, each item shows its title, its whole body, and for a question every option on its own line and the recommendation after ->:

Q4  ship it today?
    the tag is ready but CI is slow
    a. ship now
    b. wait for CI
    -> b - the release has no deadline

Inside Claude Code, kref reads the session you run it from, together with the sessions it continued from a handoff file. A code that scope lacks comes back from the newest sessions that define it. In your own terminal it reads the newest session that ran in the folder you are in, and in a folder where none ran it lists the sessions below it and asks which to open. kref search looks across every session. The kref page documents the rules and the JSON format. kref needs Node.js 22.18 or later.

Your own terminal does not have the plugin's bin/ on PATH, so link the wrapper once:

ln -s ~/.claude/katharsis/bin/kref ~/.local/bin/

The drawer

On Claude Code 2.1.287 or later (see Mods), with a Katharsis style active, the same items are one click away inside Claude Code from the start of the session, before the first prompt and after /clear. A one-row band above the prompt names the code types the session has, such as ▸ Katharsis · open | use /kdrawer · F:3|C:1|AT:2|Q:1. Hover a type for its latest 10 titles, each led by the status mark its drawer row carries, then press a title to open that item in the drawer, or press the type or its list-all button to list every item of that type.

The Katharsis band above the prompt: the pointer hovers the NA label, which pops up its 2 titles, then presses it, and the drawer lists every next action

The pointer hovers the AT label, moves up into its popup, and presses the AT2 title, which opens AT2's card in the drawer

The band's open button, or /kdrawer [query], opens the drawer, which groups every item under its type's name (Findings, Caveats, Actions taken), with a search box, a filter menu, a Status menu, a Clear button that resets the search and the filter, and a toggle between titles only and the full view. Each row shows the code, a mark, and the title: a grey ○ for an item still open or of a type that never closes, a green ✓ for one answered, settled, or done, and a red ✗ for one dismissed, dropped, or withdrawn. A yellow ! marks an open line an erratum corrected without restating it, whose title is the version the erratum replaced; its card, open or closed, carries ! Corrected by E1 and the erratum's body. The drawer opens on titles only; press a code to open that item as a card, whose first line names how it ended, such as ✓ Done in AT3 or ✗ Dropped by X2. The Status menu picks all, open, or resolved items and carries the key to the marks. Open items come first, and the setting stays between opens. Each heading counts its type under the search and filter, whatever Status hides, such as Questions (Q) · 2 open of 9. A query spelled as a code, such as F3, finds that code alone, whatever Status says. Esc closes the drawer.

The drawer: a search for timeout, Clear, the filter menu with a count per type, the Next actions filter, and the full view

In a reply, each code on record is a link that opens the drawer at that item, unless the reply is too long for Katharsis to redraw or another plugin draws it. A row of chips under the reply names the cited codes, and hovering a chip shows a card that starts with the item's status mark and what the code is, such as ○ F3 · Finding 3, followed by the item in full. The band, the drawer, and the chips draw nothing in a session where Katharsis is inactive.

A reply with its codes as links and a row of chips under it: hovering the F1 and AT2 chips shows their cards, and clicking the inline AT2 opens the drawer

Autonomy level

The autonomy level sets which actions the model takes without asking you first. Open /config, search for autonomy, and pick one of three values on the Autonomy level row; the setting's key is katharsis.autonomy.

LevelWhat changes
guided (default)Nothing. The style's "When a call is mine" test applies as written, so a push, a PR, or a message to a colleague is your call.
standardFurther publishing inside a scope you approved this session goes ahead, such as another push to a branch you approved pushing, or an update to a PR you approved opening, by adding commits. Starting something new that publishes, such as opening a new PR, and messaging people, including a comment, a review reply, or a review request on a PR, stay your call.
autonomousEverything standard allows, and also pushing a branch the work created and opening or updating a PR from it by adding commits go ahead once the work is verified, by the repo's own checks where it has them. A push to the default branch or to someone else's branch, and opening or updating a PR against a repo you can't push to, such as a third-party project reached through a fork, including a push to the branch that PR is from, are not among these additions. Every other action that is your call at guided stays your call, such as merging, deleting data the model did not create this session, force-pushing shared history, spending money, and messaging people, including a comment, a review reply, or a review request on a PR.

"Your call" means the model asks first unless it can infer your answer from what you said, the repo's conventions, or preferences you stated earlier. Deleting data it did not create this session and force-pushing shared history wait for your own words at every level, and no level's additions include a force-push, even to a branch the work created.

At standard or autonomous, hooks/register.ts adds one line to each turn's context naming the level, and the style's "Autonomy level" section, in both output-styles/ files, says what the level moves. At guided, the hook adds nothing. At every level, the model checks the repo's conventions before asking. Beyond what the level itself lets go ahead, neither standard nor autonomous widens a permission you gave for a named action past the actions and repos it names. Your own instruction files and the repo's win where they disagree with what standard or autonomous lets go ahead.

At guided, the drawer suggests standard under the latest reply once you have answered 50 questions that carried a recommendation, across every session, and taken the recommendation on at least 70% of them. Select dismiss to hide the suggestion for good; deleting

Source 6 files
hooks/register.ts 300 lines
1// register.ts: the Katharsis prompt hook. It runs wherever Claude Code loads
2// mods, as it does with no flag from 2.1.287 on, and adds the per-turn reminder: the
3// classify-then-read instruction, the inherited stamp on an untyped turn, the
4// model note, the owed items after a compaction, the autonomy level when it
5// is not guided, and the next free code numbers. The Stop hooks stay command
6// hooks in hooks.json.
7//
8// The module never says "<style> output style is active": the engine attaches
9// that sentence itself on every turn of a custom style (an `output_style`
10// attachment, seen on 2.1.278), so a second copy only costs the model a
11// repeated line.
12//
13// It reads three things from the engine rather than from files:
14//
15// - Which output style is active. `$.settings.read()` answers the merge the
16//   engine runs under, `--settings` files and policy included, so the module
17//   agrees with the system prompt by construction.
18// - Whether the user typed this turn. `e.origin.kind` is the engine's own
19//   stamp for a task notification, a bash-mode result, and every delivery a
20//   peer session, a scheduled task, or a plugin makes; text markers catch a
21//   skill load and a compaction resume, which arrive from the composer.
22// - Which model runs the main loop, from `$.session.model()`.
23//
24// A hook that throws mid-turn passes the prompt through untouched, so a
25// broken turn costs one reminder and never blocks the prompt.
26//
27// State stays where the Stop hooks and kref read it: the data directory,
28// ~/.claude/katharsis-data (KATHARSIS_DATA overrides it for tests), holding
29// .active-<sid>, .exchange-state-<sid>, .exchange-last-<sid>, .model-<sid>,
30// .model-id-<sid> and ledger/chains/<sid>, in the formats the scripts write,
31// and it creates and touches sessions/<sid>.json, the session record
32// (session.ts).
33
34import type { EngineInterface, Register } from 'claude-code';
35import { answeredOf, closersOf, latestRound, openQuestions, readAnswers } from './answers.ts';
36import { registerDrawer } from './drawer.tsx';
37import { nextFree, readRecord, recordPath, thread, threadItems, threadTexts, type Io } from './ledger.ts';
38import { KATHARSIS_STYLES, releaseOf, touched } from './session.ts';
39
40// Origins where a person typed the text, at a terminal, a bridge client, or
41// the -p command line. Every other origin is a turn nobody typed.
42const TYPED_ORIGINS = new Set(['composer', 'bridge', 'sdk']);
43
44// Untyped turns the origin cannot tell apart from a typed one.
45const TEXT_MARKERS: ReadonlyArray<readonly [string, string]> = [
46  ['<bash-input>', 'bash-input'],
47  ['<task-notification>', 'task-notification'],
48  ['<command-name>', 'skill'],
49  ['Base directory for this skill', 'skill'],
50  ['<local-command-stdout>', 'local-command'],
51  ['This session is being continued from a previous conversation', 'compaction-resume'],
52];
53
54// The owed-work codes a compaction-resume turn lists, and how many at most.
55// The oldest are kept: next actions start first item first, and the summary
56// is likeliest to have dropped what was owed longest.
57const OWED = ['NA', 'MV', 'W', 'B', 'Q'];
58const OWED_MAX = 12;
59
60const CLASSIFY_LINES = [
61  'Classify the user\'s message by exchange type and read the matching guidance file in ~/.claude/katharsis/styles/ before shaping the reply.',
62  'Before sending the reply, re-read what the user actually asked and run that guidance file\'s Verification section against your draft.',
63];
64
65export function turnKind(text: string, originKind: string): string {
66  if (!TYPED_ORIGINS.has(originKind)) return originKind;
67  for (const [marker, kind] of TEXT_MARKERS) {
68    if (text.includes(marker)) return kind;
69  }
70  return 'typed';
71}
72
73// The ledger's files through the engine's filesystem. drawer.tsx has its own
74// copy, since the engine never lets $ cross an import.
75function engineIo($: EngineInterface): Io {
76  return {
77    read: async (path) => ((await $.fs.exists(path)) ? String(await $.fs.read(path)) : null),
78    list: async (dir) => ((await $.fs.exists(dir)) ? $.fs.list(dir) : []),
79  };
80}
81
82// One part of an owed item, on one line and at most 200 characters.
83function clipLine(t: string): string {
84  const one = t.replace(/\s+/g, ' ').replace(/"/g, "'").trim();
85  return one.length > 200 ? `${one.slice(0, 199)}…` : one;
86}
87
88function isoNow(): string {
89  return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
90}
91
92// The session record after this prompt. cwd and branch are read only when
93// the record is new, so the git call runs once per session. The record is
94// read last, right before the write, so a title the turn.complete hook wrote
95// meanwhile survives. A record that exists but won't parse is left alone.
96async function touchRecord($: EngineInterface, io: Io, data: string, sid: string, home: string): Promise<void> {
97  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`;
98  const root = $.plugin.root;
99  const release = releaseOf(
100    root,
101    await io.read(`${root}/.claude-plugin/plugin.json`),
102    await io.read(`${config}/plugins/installed_plugins.json`),
103  );
104  const parent = (await io.read(`${data}/ledger/chains/${sid}`))?.trim() ?? '';
105  let old = await readRecord(io, data, sid);
106  let cwd = '';
107  let branch = '';
108  if (!old) {
109    if ((await io.read(recordPath(data, sid))) !== null) return;
110    cwd = await $.session.cwd();
111    const git = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 2000 }).catch(() => null);
112    if (git?.exitCode === 0) branch = git.stdout.trim();
113    old = await readRecord(io, data, sid);
114  }
115  const rec = touched(old, { id: sid, now: isoNow(), cwd, branch, parent, release });
116  await $.fs.write(recordPath(data, sid), `${JSON.stringify(rec, null, 2)}\n`);
117}
118
119export const register: Register = (on, options) => {
120  // The plugin's `autonomy` option (plugin.json userConfig). Options are fixed
121  // for one activation, and a change in /config reloads the module.
122  // The engine hands an unset or unknown value over as the default, guided,
123  // which is the test as written and adds no line.
124  const autonomy = options.autonomy === 'standard' || options.autonomy === 'autonomous' ? options.autonomy : '';
125
126  on('prompt.submit', async ($, e, next) => {
127    const home = (await $.env.get('HOME')) ?? '';
128    const data = (await $.env.get('KATHARSIS_DATA')) ?? `${home}/.claude/katharsis-data`;
129    const sid = await $.session.id();
130    const marker = `${data}/.active${sid ? `-${sid}` : ''}`;
131
132    const settings = await $.settings.read();
133    const style = typeof settings.outputStyle === 'string' ? settings.outputStyle : '';
134
135    // A session that switched away from Katharsis drops its marker here, so
136    // the Stop hooks go idle on the same turn.
137    if (!KATHARSIS_STYLES.has(style)) {
138      await $.process.run(['rm', '-f', marker]);
139      return next(e);
140    }
141
142    await $.fs.write(marker, '');
143    const lines: string[] = [];
144
145    // The handoff chain link: a punt opening names a punt file, and that file
146    // names the session that wrote it. Recording new -> parent makes the pair
147    // one numbering space for kref and for the counter line below.
148    const punt = e.text.match(/\/tmp\/punt-[A-Za-z0-9]+\.md/)?.[0];
149    if (punt && sid && (await $.fs.exists(punt))) {
150      const text = String(await $.fs.read(punt));
151      const parent = text.match(/^Ledger parent:[ \t]*([A-Za-z0-9-]*)/m)?.[1];
152      if (parent && parent !== sid) {
153        await $.fs.write(`${data}/ledger/chains/${sid}`, `${parent}\n`);
154      }
155    }
156
157    // The session record, after the chain link so it carries the parent. A
158    // failure here costs the record one update and never the reminder.
159    const io = engineIo($);
160    if (sid) await touchRecord($, io, data, sid, home).catch(() => undefined);
161
162    // A turn nobody typed inherits the last typed message's type. That needs
163    // no judgment, so the module stamps it instead of asking the model to.
164    const kind = turnKind(e.text, e.origin.kind);
165    if (kind === 'typed') {
166      lines.push(...CLASSIFY_LINES);
167    } else {
168      const last = `${data}/.exchange-last${sid ? `-${sid}` : ''}`;
169      const stamp = `${data}/.exchange-state${sid ? `-${sid}` : ''}`;
170      let primary = '';
171      if (await $.fs.exists(last)) {
172        primary = String(await $.fs.read(last)).split('\t')[1]?.trim() ?? '';
173      }
174      if (primary) {
175        await $.fs.write(stamp, `${isoNow()}\t${primary}\tinherited\n`);
176        lines.push(
177          `Untyped turn (${kind}): it inherits \`${primary}\` from the last typed message, and the stamp is already made. Shape the reply to that type; do not run the script.`,
178        );
179      } else {
180        lines.push(
181          `Untyped turn (${kind}) with no earlier type in this session: treat it as \`status-and-resume\` and run the script with that type.`,
182        );
183      }
184    }
185
186    if (autonomy) {
187      lines.push(`Autonomy level: ${autonomy}. The style's "Autonomy level" section moves which calls are the user's.`);
188    }
189
190    // Answers to the latest Questions round, read from the message itself
191    // (answers.ts), so the open-questions line needs no model call. A reading
192    // the parser would have to guess goes to the model to confirm instead.
193    // A failed ledger read skips the lines built from it, never the rest.
194    const items = sid ? await threadItems(io, data, sid).catch(() => null) : [];
195    if (kind === 'typed' && sid && items) {
196      const round = latestRound(items);
197      if (round.length > 0) {
198        const asked = new Map(
199          items.filter((i) => i.prefix === 'Q').map((i) => [i.code.toUpperCase(), i.options.map((o) => o.key.toLowerCase())]),
200        );
201        const { answers, unclear } = readAnswers(e.text, round, asked);
202        if (answers.length > 0) {
203          const file = `${data}/answers/${sid}.jsonl`;
204          const before = (await $.fs.exists(file)) ? String(await $.fs.read(file)) : '';
205          const now = isoNow();
206          const rows = answers.map((a) => JSON.stringify({ ts: now, code: a.code, letter: a.letter, how: a.how }));
207          await $.fs.write(file, `${before}${rows.join('\n')}\n`);
208        }
209        const dropped = answers.filter((a) => a.how === 'dismissed').map((a) => a.code);
210        if (dropped.length > 0) {
211          lines.push(`Dismissed: ${dropped.join(', ')}. The user no longer wants ${dropped.length > 1 ? 'these' : 'this'} settled, so drop ${dropped.length > 1 ? 'them' : 'it'}: act on no option and do not ask again.`);
212        }
213        for (const u of unclear) {
214          const reading = u.letter ? `${u.code} ${u.letter}` : u.code;
215          lines.push(
216            u.why === 'position'
217              ? `The message's "${u.said}" names no question in the round, so it reads by position as ${reading}. Confirm that reading in one line before acting on it, and suggest answering as \`${u.code} ${u.letter || 'a'}\` next time, or \`${u.code} z\` for an answer of their own.`
218              : `The message's "${u.said}" picks option ${u.letter}, which ${u.code} does not offer. Ask which option was meant, and suggest answering as \`${u.code} <letter>\`, or \`${u.code} z\` for an answer of their own.`,
219          );
220        }
221      }
222      const answered = answeredOf(await threadTexts(io, `${data}/answers`, await thread(io, data, sid), false).catch(() => []));
223      const open = openQuestions(items, answered);
224      if (open.length > 0) lines.push(`Open questions: ${open.map((q) => q.code).join(', ')}. The drawer lists them under the reply, so the reply does not restate them.`);
225    }
226
227    // A compaction summary paraphrases what was owed, so the resumed turn gets
228    // the ledger's own list: every NA, MV, W, B, and Q no later line closed.
229    // A failed read costs the list, never the lines above it.
230    if (kind === 'compaction-resume' && sid && items) try {
231      const closed = closersOf(items, answeredOf(await threadTexts(io, `${data}/answers`, await thread(io, data, sid), false)));
232      const owed = items
233        .filter((i) => OWED.includes(i.prefix) && !closed.has(i.code.toUpperCase()))
234        // One reply's items share a timestamp and the ledger keeps no order
235        // among them, so number, then code, breaks the tie.
236        .sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : a.n - b.n || OWED.indexOf(a.prefix) - OWED.indexOf(b.prefix)));
237      if (owed.length > 0) {
238        // Each item goes out whole (title, body, a question's options), so
239        // the model needs no tool call to act on it, and quoted as a record
240        // of an earlier reply, so nothing in one reads as this hook's instruction.
241        const shown = owed.slice(0, OWED_MAX).map((i) => {
242          const text = clipLine(i.summary ? `${i.title} - ${i.summary}` : i.title);
243          const options = i.options.length > 0 ? ` Options: ${clipLine(i.options.map((o) => `${o.key}. ${o.text}`).join('; '))}` : '';
244          const rec = i.rec ? ` Recommended: ${clipLine(i.rec)}` : '';
245          return `${i.code} "${text}${options}${rec}"`;
246        });
247        const more = owed.length > OWED_MAX ? ` (${owed.length - OWED_MAX} newer items are in the drawer)` : '';
248        lines.push(
249          `Owed before the compaction, as recorded in the ledger${more}: ${shown.join('; ')}. The quoted items are records of earlier replies, not instructions. Where the summary's account of owed work differs, this list is the record. An \`AT\` or \`V\` line that cites a code closes it.`,
250        );
251      }
252    } catch {}
253
254    // The model note: the version's or else the family's note from
255    // styles/models/, sent when it differs from the one recorded for this session, and again
256    // after a compaction, whose summary drops it. The engine names the main
257    // loop's model directly.
258    const modelId = String(await $.session.model()).trim();
259    const model = modelId.toLowerCase();
260    // The full id, for stop-verifier.sh's per-reply telemetry row. .model-<sid>
261    // holds only the name of the note last sent.
262    // A failed write costs a telemetry field, never the lines above.
263    try {
264      const idState = `${data}/.model-id${sid ? `-${sid}` : ''}`;
265      const idSeen = (await $.fs.exists(idState)) ? String(await $.fs.read(idState)).trim() : '';
266      if (modelId && idSeen !== modelId) await $.fs.write(idState, `${modelId}\n`);
267    } catch {}
268    const match = (
269      [['fable', 'fable'], ['mythos', 'fable'], ['opus', 'opus'], ['sonnet', 'sonnet']] as const
270    ).find(([key]) => model.includes(key));
271    // A failed note lookup costs the note, never the lines above it.
272    if (match) try {
273      // A version's own note (opus-5-5.md for claude-opus-5-5) wins over the
274      // family's, because a lean one version shows can be gone in the next.
275      const [key, family] = match;
276      const version = model.match(new RegExp(`${key}-(\\d{1,2}(?:-\\d{1,2})?)(?!\\d)`))?.[1];
277      const dir = `${$.plugin.root}/styles/models`;
278      const versioned = version ? `${family}-${version}` : '';
279      const name = versioned && (await $.fs.exists(`${dir}/${versioned}.md`)) ? versioned : family;
280      const state = `${data}/.model${sid ? `-${sid}` : ''}`;
281      const seen = (await $.fs.exists(state)) ? String(await $.fs.read(state)).trim() : '';
282      const note = `${dir}/${name}.md`;
283      if ((seen !== name || kind === 'compaction-resume') && (await $.fs.exists(note))) {
284        lines.push(String(await $.fs.read(note)).trim());
285        await $.fs.write(state, `${name}\n`);
286      }
287    } catch {}
288
289    // One line of counters from the ledger, so numbering survives compaction
290    // and handoffs.
291    const counters = items ? nextFree(items) : '';
292    if (counters) lines.push(counters);
293
294    return next({ ...e, context: [...(e.context ?? []), lines.join('\n')] });
295  }).catch(async ($, e, next) => next(e));
296
297  // The drawer (drawer.tsx): the band, the pane, /kdrawer and the reply chips.
298  registerDrawer(on, autonomy);
299};
300
hooks/answers.ts 266 lines
1// answers.ts: which questions are still open, decided without the model.
2//
3// The prompt hook (register.ts) reads each typed message for answers to the
4// latest Questions round and records them to answers/<sid>.jsonl in the data
5// directory. The drawer reads that file to draw the Still open row. No
6// model call is made: a replay over 1,226 real answers found the patterns
7// below catch the answers people type. A miss leaves the question listed
8// until it is answered again as `Q3 a` or a later line settles it; a
9// question never drops out of sight unsettled.
10//
11// An answer is a number, an optional separator, and a letter: `1. a`, `1a`,
12// `Q2: b`, `1a, 2b`, `1a 2b`, one per line, with anything after the letter
13// ignored. A token starts a line or follows a comma or semicolon, or follows
14// another token on the same line, so "I merged 2 a while ago" matches
15// nothing. A number with prose after it, "54. I fixed it in Jira", answers
16// that question in prose.
17//
18// The number resolves in this order:
19//   1. Q2 or q2 is always Q2.
20//   2. A bare number naming a question in the latest round is that question.
21//   3. Otherwise a bare number with a letter is a position in the round, and
22//      the model is asked to confirm that reading rather than act on a guess.
23//   4. A bare number past the round's length naming an earlier question is
24//      that question, when the letter is one it offers, `z`, or `x` and no
25//      word follows it: the drawer keeps an unanswered question listed, and
26//      "2 a" answers it there. A numbered prose line, "2. A file was found",
27//      never reaches that far back.
28// A letter the question does not offer is not guessed at either, except `z`,
29// which every question takes as "my own answer": the drawer's Still open
30// row says so, and `x`, which dismisses any question, even one offering an
31// option x. The words dismiss, dismissed, cancel, and canceled stand in for
32// the `x`. A dismissal needs nothing but a separator after it: "Q3 x - stale"
33// dismisses, and "Q3 x is undefined" answers in prose.
34
35export type Round = { code: string; options: string[] }[];
36export type Answer = { code: string; letter: string; how: 'code' | 'number' | 'prose' | 'own' | 'dismissed' };
37export type Unclear = { code: string; letter: string; said: string; why: 'position' | 'option' };
38
39const TOKEN = String.raw`(q?)(\d{1,3})\s*[.):=\-]?\s*(dismiss(?:ed)?|cancel(?:l?ed)?|[a-z])(?![a-z0-9]|-[a-z0-9])`;
40const LEAD = new RegExp(String.raw`^\s*` + TOKEN, 'iy');
41const CHAIN = new RegExp(String.raw`(?:\s*[,;]\s*|\s+)` + TOKEN, 'iy');
42const SEP = new RegExp(String.raw`[,;]\s*` + TOKEN, 'ig');
43const PROSE = /^\s*(q?)(\d{1,3})\s*[.):]\s+[a-z]{2,}/i;
44
45type Token = { explicit: boolean; n: number; letter: string; wordAfter: boolean; said: string };
46
47function tokensOf(msg: string): Token[] {
48  const out: Token[] = [];
49  const take = (m: RegExpExecArray, line: string) => {
50    const end = m.index + m[0].length;
51    const wordAfter = /^\s+[a-z]/i.test(line.slice(end));
52    out.push({
53      explicit: m[1] !== '',
54      n: Number(m[2]),
55      // "1. Cancel the build" is a sentence, not a dismissal.
56      letter: m[3]!.length > 1 ? (wordAfter ? '' : 'x') : m[3]!.toLowerCase(),
57      wordAfter,
58      said: m[0].replace(/^[\s,;]+/, ''),
59    });
60    return end;
61  };
62  for (const line of msg.split('\n')) {
63    let pos = 0;
64    LEAD.lastIndex = 0;
65    let m = LEAD.exec(line);
66    const led = m !== null;
67    while (m) {
68      pos = take(m, line);
69      CHAIN.lastIndex = pos;
70      m = CHAIN.exec(line);
71    }
72    SEP.lastIndex = pos;
73    for (let s = SEP.exec(line); s; s = SEP.exec(line)) take(s, line);
74    if (!led) {
75      const p = line.match(PROSE);
76      if (p) out.push({ explicit: p[1] !== '', n: Number(p[2]), letter: '', wordAfter: true, said: p[0].trim() });
77    }
78  }
79  return out;
80}
81
82// The answers a message gives to the latest round, and the readings it leaves
83// for the model to confirm. `asked` holds every question on record, so an
84// explicit code outside the round still resolves.
85export function readAnswers(msg: string, round: Round, asked: Map<string, string[]>): { answers: Answer[]; unclear: Unclear[] } {
86  const answers: Answer[] = [];
87  const unclear: Unclear[] = [];
88  const inRound = new Map(round.map((q) => [q.code, q.options]));
89  const seen = new Set<string>();
90  for (const t of tokensOf(msg)) {
91    let code = `Q${t.n}`;
92    let opts: string[] | undefined;
93    let how: Answer['how'] = t.explicit ? 'code' : 'number';
94    let far = false;
95    if (t.explicit) opts = asked.get(code);
96    else if (inRound.has(code)) opts = inRound.get(code);
97    else if (t.letter !== '' && t.n >= 1 && t.n <= round.length) {
98      // A numbered prose line is never read by position: "1. Fix the tests"
99      // is more often a list than an answer.
100      code = round[t.n - 1]!.code;
101      if (!seen.has(code)) unclear.push({ code, letter: t.letter, said: t.said, why: 'position' });
102      seen.add(code);
103      continue;
104    } else if (t.letter !== '') {
105      opts = asked.get(code);
106      far = true;
107    }
108    if (opts === undefined || seen.has(code)) continue;
109    if (far && (t.wordAfter || (!opts.includes(t.letter) && t.letter !== 'z' && t.letter !== 'x'))) continue;
110    seen.add(code);
111    if (t.letter === 'z' && !opts.includes('z')) {
112      answers.push({ code, letter: 'z', how: 'own' });
113    } else if (t.letter === 'x' && !t.wordAfter) {
114      answers.push({ code, letter: 'x', how: 'dismissed' });
115    } else if (t.letter === '' || (!opts.includes(t.letter) && t.wordAfter)) {
116      // "54. I fixed it in Jira": the letter is the first word of a prose answer.
117      answers.push({ code, letter: '', how: 'prose' });
118    } else if (opts.length > 0 && !opts.includes(t.letter)) {
119      unclear.push({ code, letter: t.letter, said: t.said, why: 'option' });
120    } else {
121      answers.push({ code, letter: t.letter, how });
122    }
123  }
124  return { answers, unclear };
125}
126
127// Every question code an answers file's rows name, with the letter the
128// latest row for it gave.
129export function answeredOf(texts: string[]): Map<string, string> {
130  const out = new Map<string, string>();
131  for (const text of texts) {
132    for (const line of text.split('\n')) {
133      try {
134        const r = JSON.parse(line) as Record<string, unknown>;
135        if (typeof r.code === 'string') out.set(r.code.toUpperCase(), String(r.letter ?? ''));
136      } catch {
137        continue; // a blank or partial line
138      }
139    }
140  }
141  return out;
142}
143
144type Q = { code: string; prefix: string; n: number; ts: string; title: string; summary: string };
145
146// What closed a code: the answer letter, the line that cited it, or both.
147export type Closer = { letter: string; by: string; prefix: string; title: string };
148
149const CITE = /(?<![A-Za-z0-9-])[A-Z][A-Z-]{0,3}\d+(?!\d)/g;
150// A code span: a run of backticks, then text up to a run of the same length,
151// across a line break too, since a title and its body are one paragraph.
152const SPAN = /(?<!`)(`+)(?!`)([\s\S]*?[^`])\1(?!`)/g;
153
154// Every code a later coded line cites, with the citing items, oldest first.
155// A mention in the reply's prose is not on record, so only a coded line's
156// title and body count, and a code inside a code span is an example, such as
157// `kref Q4` or `Q3 a`, rather than a citation.
158export function citersOf<T extends Q>(items: T[]): Map<string, T[]> {
159  const byTs = [...items].sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
160  const ts = new Map(items.map((i) => [i.code.toUpperCase(), i.ts]));
161  const out = new Map<string, T[]>();
162  for (const j of byTs) {
163    for (const c of new Set(`${j.title}\n${j.summary}`.replace(SPAN, ' ').match(CITE) ?? [])) {
164      const at = ts.get(c);
165      if (at === undefined || c === j.code.toUpperCase() || j.ts <= at) continue;
166      out.set(c, [...(out.get(c) ?? []), j]);
167    }
168  }
169  return out;
170}
171
172// Which lines close each type: owed work, a block, a risk, and a question
173// close on the action or check that did it or the exclusion that dropped it,
174// and a question on an answer too. A caveat closes on the action or check
175// that lifted its limit. Only these lines count, so an erratum or a finding
176// that cites a code leaves it open. A finding closes only when an erratum
177// withdraws it, and the other types record something and never close.
178const DONE = (p: string) => p === 'AT' || p === 'V' || p === 'X';
179const CLOSES: Record<string, (p: string) => boolean> = {
180  Q: DONE,
181  NA: DONE,
182  MV: DONE,
183  W: DONE,
184  B: DONE,
185  R: DONE,
186  C: (p) => p === 'AT' || p === 'V',
187};
188
189// The types that can close at all.
190export const CLOSING = new Set(Object.keys(CLOSES));
191
192// A withdrawn finding is restated as `F3 - **Withdrawn: <why>** - (E1)`, and
193// the ledger keeps that restatement as the code's current line.
194const WITHDRAWN = /^Withdrawn:/i;
195const ERRATUM = /\((E\d+)\)\s*$/;
196
197// An exclusion line dismisses a question only while it is unanswered, and
198// only when it names the question rather than one option, as `Q16c` does.
199function keepsQuestion(key: string, x: Q, answered: ReadonlyMap<string, string>): boolean {
200  return answered.has(key) || new RegExp(`(?<![A-Za-z0-9-])${key}[a-z](?![A-Za-z])`).test(`${x.title}\n${x.summary}`);
201}
202
203// Every closed code and what closed it.
204export function closersOf(items: Q[], answered: ReadonlyMap<string, string>): Map<string, Closer> {
205  const citers = citersOf(items);
206  const out = new Map<string, Closer>();
207  for (const i of items) {
208    const key = i.code.toUpperCase();
209    if (i.prefix === 'F') {
210      if (!WITHDRAWN.test(i.title)) continue;
211      const e = `${i.title}\n${i.summary}`.trim().match(ERRATUM)?.[1] ?? '';
212      out.set(key, { letter: '', by: e, prefix: e ? 'E' : '', title: '' });
213      continue;
214    }
215    const closes = CLOSES[i.prefix];
216    if (!closes) continue;
217    const by = (citers.get(key) ?? []).find((j) => closes(j.prefix) && !(i.prefix === 'Q' && j.prefix === 'X' && keepsQuestion(key, j, answered)));
218    const letter = i.prefix === 'Q' ? (answered.get(key) ?? '') : '';
219    if (by || answered.has(key)) out.set(key, { letter, by: by?.code ?? '', prefix: by?.prefix ?? '', title: by?.title ?? '' });
220  }
221  return out;
222}
223
224// An erratum names its target in its title, `F3 as first written: <old>`, and
225// the corrected line should go out again under F3 in the same reply. When it
226// does not, the ledger still holds the wrong line as F3's current one. Every
227// code whose latest line is not newer than an erratum correcting it and does
228// not end citing it, with that erratum, the newest when several do. Stamps
229// are to the second, so a line from an earlier reply can share the
230// erratum's; only the citation tells a same-reply restatement apart.
231const FIRST_WRITTEN = /^([A-Z][A-Z-]{0,3}\d+) as first written/;
232export function correctionsOf<T extends Q>(items: T[]): Map<string, T> {
233  const latest = new Map(items.map((i) => [i.code.toUpperCase(), i]));
234  const out = new Map<string, T>();
235  for (const e of items) {
236    if (e.prefix !== 'E') continue;
237    const key = e.title.match(FIRST_WRITTEN)?.[1];
238    const line = key ? latest.get(key) : undefined;
239    if (!key || !line || line.ts > e.ts) continue;
240    if (new RegExp(`\\(${e.code}\\)\\W*$`, 'i').test(`${line.title}\n${line.summary}`.trim())) continue;
241    const old = out.get(key);
242    if (!old || e.ts > old.ts) out.set(key, e);
243  }
244  return out;
245}
246
247// The open questions, oldest first: every question on record that no answer
248// row names and no later action-taken, verification, or exclusion line cites.
249export function openQuestions<T extends Q>(items: T[], answered: ReadonlyMap<string, string>): T[] {
250  const closed = closersOf(items, answered);
251  return items
252    .filter((i) => i.prefix === 'Q' && !closed.has(i.code.toUpperCase()))
253    .sort((a, b) => a.n - b.n);
254}
255
256// The latest Questions round: the Q rows the newest reply with a round wrote,
257// which share that reply's timestamp.
258export function latestRound(items: (Q & { options: { key: string }[] })[]): Round {
259  const qs = items.filter((i) => i.prefix === 'Q');
260  const last = qs.reduce((m, i) => (i.ts > m ? i.ts : m), '');
261  return qs
262    .filter((i) => i.ts === last)
263    .sort((a, b) => a.n - b.n)
264    .map((i) => ({ code: i.code, options: i.options.map((o) => o.key.toLowerCase()) }));
265}
266
hooks/drawer.tsx 1126 lines
1// drawer.tsx: the Katharsis drawer. A one-row band above the prompt names
2// the code types this session has, each with a hover list of its titles; its
3// button, or /kdrawer [query], opens a pane that lists every item grouped by
4// type, searchable by text and filterable by type and by status. In a reply,
5// each code on record becomes a link that opens the pane at that code, and a
6// row of chips under the reply carries a hover card per code.
7//
8// It reads the ledger through ledger.ts (one handoff chain is one numbering
9// space, a later record for a code supersedes an earlier one), and only while
10// the output style is Katharsis. It reads the style from settings rather than
11// the .active-<sid> marker register.ts writes at each prompt, because the
12// marker is missing until the first prompt of a new, forked, or cleared
13// session. The render hooks draw from a cache that session start, the band's
14// first drawing, the end of each turn, and every pane open refresh, so no
15// reply block waits on the filesystem. The refresh at session start registers
16// /kdrawer before the first prompt.
17//
18// Every hook falls through to next(e) when it throws: the drawer may vanish,
19// but it never stands between the person and the session.
20
21import type { EngineInterface, On } from 'claude-code';
22import { answeredOf, citersOf, CLOSING, closersOf, correctionsOf, openQuestions, type Closer } from './answers.ts';
23import { codeOrder, readRecord, recordPath, thread, threadItems, threadTexts, type Io, type Item } from './ledger.ts';
24import { cleanTitle, KATHARSIS_STYLES, TITLE_PROMPT, wantsTitle, withTranscript } from './session.ts';
25import { agreement, suggestsStandard, type Agreement } from './suggest.ts';
26
27const PANE = 'kdrawer';
28const TITLE = 'Katharsis';
29const TEAL = '#14B8A6';
30const CODE_RE = /(?<![A-Za-z0-9-])[A-Z][A-Z-]{0,3}\d+(?![A-Za-z0-9])/g;
31const CODE_ONLY = /^[A-Za-z][A-Za-z-]{0,3}\d+$/;
32// Inline links point here. The host is reserved and never resolves, and the
33// path spells the code out for a terminal that shows a link's target on hover.
34const LINK_BASE = 'https://katharsis.invalid/';
35const MARKDOWN_MAX = 10000;
36// Titles a band reveal lists at most, so it never outgrows a short band.
37const REVEAL_MAX = 10;
38// The Still open row: the types someone has to act on, the person's first,
39// and the newest codes it shows of each. Next actions and waits are the
40// model's to finish, so the pane's filter holds them instead.
41const STILL_OPEN = ['Q', 'MV', 'B', 'R'];
42const STILL_OPEN_SHOWN = 3;
43// Sessions that show the answer hint on the row itself; later ones show it
44// only in a question's hover card.
45const HINT_SESSIONS = 3;
46const SUGGEST_DISMISSED = 'autonomy-suggestion-dismissed';
47
48// Each code's name, singular then plural. D is retired but still appears in
49// older ledgers.
50const TYPES: [string, string, string][] = [
51  ['F', 'Finding', 'Findings'],
52  ['A', 'Assumption', 'Assumptions'],
53  ['R', 'Risk', 'Risks'],
54  ['C', 'Caveat', 'Caveats'],
55  ['AT', 'Action taken', 'Actions taken'],
56  ['V', 'Verification', 'Verified'],
57  ['NA', 'Next action', 'Next actions'],
58  ['B', 'Blocked', 'Blocked'],
59  ['MV', 'Your move', 'Your moves'],
60  ['W', 'Waiting', 'Waiting'],
61  ['X', 'Excluded', 'Excluded'],
62  ['S', 'State', 'State'],
63  ['T-O', 'Trade-off', 'Trade-offs'],
64  ['E', 'Erratum', 'Errata'],
65  ['Q', 'Question', 'Questions'],
66  ['D', 'Decision', 'Decisions'],
67];
68const SINGULAR = new Map(TYPES.map(([p, s]) => [p, s]));
69const PLURAL = new Map(TYPES.map(([p, , pl]) => [p, pl]));
70
71// "Finding 9" for F9; an invented code keeps its own spelling.
72export function nameOf(i: Item): string {
73  const s = SINGULAR.get(i.prefix);
74  return s ? `${s} ${i.n}` : i.code;
75}
76
77// A Button label is one line, so a long title is cut to fit.
78function clip(s: string, width: number): string {
79  return s.length <= width ? s : `${s.slice(0, width - 1)}…`;
80}
81
82// A menu row: the name on the left and the count flush right in `width` cells.
83// A name too long for the row is cut, so the row never wraps onto a second line.
84function menuRow(name: string, count: string, width: number): string {
85  const cut = clip(name, Math.max(2, width - count.length - 1));
86  return `${cut}${' '.repeat(Math.max(1, width - cut.length - count.length))}${count}`;
87}
88
89function groupName(p: string): string {
90  const pl = PLURAL.get(p);
91  return pl ? `${pl} (${p})` : p;
92}
93
94// Types sorted by the label the reader scans: the band shows codes, so it
95// sorts by code; the filter menu and the pane's groups show names, so they
96// sort by name.
97const alpha = (a: string, b: string) => a.localeCompare(b, 'en');
98function byCode(ps: string[]): string[] {
99  return [...ps].sort(alpha);
100}
101function byName(ps: string[]): string[] {
102  return [...ps].sort((a, b) => alpha(groupName(a), groupName(b)));
103}
104// The ledger's files through the engine's filesystem. register.ts has its own
105// copy, since the engine never lets $ cross an import.
106function engineIo($: EngineInterface): Io {
107  return {
108    read: async (path) => ((await $.fs.exists(path)) ? String(await $.fs.read(path)) : null),
109    list: async (dir) => ((await $.fs.exists(dir)) ? $.fs.list(dir) : []),
110  };
111}
112
113type Show = 'all' | 'open' | 'resolved';
114const SHOWS: Show[] = ['all', 'open', 'resolved'];
115
116type State = {
117  active: boolean;
118  loaded: boolean;
119  commandRegistered: boolean;
120  items: Item[];
121  query: string;
122  prefix: string;
123  // Which items the pane shows by status. It outlives a pane close, unlike
124  // the rest of the pane's state.
125  show: Show;
126  full: boolean;
127  selected: string;
128  filterOpen: boolean;
129  statusOpen: boolean;
130  paneOpen: boolean;
131  answered: Map<string, string>;
132  closed: Map<string, Closer>;
133  citedBy: Map<string, Item[]>;
134  // Codes an erratum corrected without restating them, with that erratum.
135  corrected: Map<string, Item>;
136  // Whether this session shows the answer hint on the Still open row.
137  hint: boolean;
138  // The last finished reply's text, which tells the latest reply block apart.
139  lastAnswer: string;
140  // A code list the pane shows alone: the still open codes.
141  only: string[];
142  // The autonomy level register() got, '' for guided.
143  level: string;
144  // The answers behind a suggestion to move to standard, checked once a
145  // session, and null when there is none to make. scan marks this session's
146  // check, so one still running when a reset starts another is ignored.
147  suggestion: Agreement | null;
148  scan: symbol | null;
149};
150
151export async function loadLedger($: EngineInterface): Promise<{ active: boolean; items: Item[] }> {
152  const home = (await $.env.get('HOME')) ?? '';
153  const data = (await $.env.get('KATHARSIS_DATA')) ?? `${home}/.claude/katharsis-data`;
154  const sid = await $.session.id();
155  const style = (await $.settings.read()).outputStyle;
156  if (!sid || typeof style !== 'string' || !KATHARSIS_STYLES.has(style)) return { active: false, items: [] };
157  return { active: true, items: await threadItems(engineIo($), data, sid) };
158}
159
160// Every question code an answer row names, across the handoff chain.
161export async function loadAnswered($: EngineInterface): Promise<Set<string>> {
162  const home = (await $.env.get('HOME')) ?? '';
163  const data = (await $.env.get('KATHARSIS_DATA')) ?? `${home}/.claude/katharsis-data`;
164  const sid = await $.session.id();
165  if (!sid) return new Set();
166  const io = engineIo($);
167  return answeredOf(await threadTexts(io, `${data}/answers`, await thread(io, data, sid), false));
168}
169
170// The answer hint shows on the row in the first 3 sessions that drew it. The
171// file holds their ids, one per line, and deleting it starts the count over.
172async function hintHere($: EngineInterface, drawn: boolean): Promise<boolean> {
173  const home = (await $.env.get('HOME')) ?? '';
174  const data = (await $.env.get('KATHARSIS_DATA')) ?? `${home}/.claude/katharsis-data`;
175  const sid = await $.session.id();
176  if (!sid) return false;
177  const f = `${data}/hint-sessions`;
178  const ids = (await $.fs.exists(f)) ? String(await $.fs.read(f)).split('\n').filter(Boolean) : [];
179  if (ids.includes(sid)) return true;
180  if (!drawn || ids.length >= HINT_SESSIONS) return false;
181  await $.fs.write(f, `${[...ids, sid].join('\n')}\n`);
182  return true;
183}
184
185// The suggestion to move from guided to standard, made only at guided and
186// only until the person dismisses it. The dismissal is a file in the data
187// directory, and deleting it brings the suggestion back. Another open session
188// can write it at any time, so it is checked again once the scan is done.
189async function suggestionHere($: EngineInterface): Promise<Agreement | null> {
190  if (S.level || (await suggestDismissed($))) return null;
191  const a = await agreement(engineIo($), await dataDir($));
192  return suggestsStandard(a) && !(await suggestDismissed($)) ? a : null;
193}
194
195async function suggestDismissed($: EngineInterface): Promise<boolean> {
196  return $.fs.exists(`${await dataDir($)}/${SUGGEST_DISMISSED}`);
197}
198
199function fresh(): State {
200  return {
201    active: false,
202    loaded: false,
203    commandRegistered: false,
204    items: [],
205    query: '',
206    prefix: 'all',
207    show: 'all',
208    full: false,
209    selected: '',
210    filterOpen: false,
211    statusOpen: false,
212    paneOpen: false,
213    answered: new Map(),
214    closed: new Map(),
215    citedBy: new Map(),
216    corrected: new Map(),
217    hint: false,
218    lastAnswer: '',
219    only: [],
220    level: '',
221    suggestion: null,
222    scan: null,
223  };
224}
225
226// Module state: the render hooks read it, the press and input closures
227// mutate it and invalidate. A function that takes $ must be declared at the
228// top of the file (the engine checks where $ goes), hence module scope.
229const S: State = fresh();
230
231async function refresh($: EngineInterface): Promise<void> {
232  const r = await loadLedger($);
233  S.active = r.active;
234  S.items = r.items;
235  S.answered = r.active ? await loadAnswered($) : new Map();
236  S.closed = closersOf(S.items, S.answered);
237  S.citedBy = citersOf(S.items);
238  S.corrected = correctionsOf(S.items);
239  S.hint = r.active ? await hintHere($, openQuestions(S.items, S.answered).length > 0) : false;
240  // A suggestion another session dismissed goes at this session's next
241  // refresh. The scan reads every answers file, so it runs beside the
242  // refresh rather than inside it, and a failed scan just makes none.
243  if (S.suggestion && (await suggestDismissed($))) S.suggestion = null;
244  if (r.active && !S.scan) {
245    const scan = Symbol('scan');
246    S.scan = scan;
247    void suggestionHere($)
248      .then((a) => {
249        if (!a || S.scan !== scan || S.level) return;
250        S.suggestion = a;
251        $.ui.invalidate('ui.render');
252      })
253      .catch(() => undefined);
254  }
255  S.loaded = true;
256  if (S.active && !S.commandRegistered) {
257    await $.command.register({
258      name: PANE,
259      description: 'Open the Katharsis drawer: every coded item this session, searchable',
260      argumentHint: '[query]',
261      immediate: true,
262    });
263    S.commandRegistered = true;
264  }
265}
266
267async function redrawFresh($: EngineInterface): Promise<void> {
268  await refresh($);
269  $.ui.invalidate('ui.render');
270}
271
272function prefixes(): string[] {
273  return [...new Set(S.items.map((i) => i.prefix))];
274}
275
276// The Still open row's groups, in STILL_OPEN order, each type's unclosed
277// codes with the newest few shown and the rest counted, so nothing open
278// drops out of sight.
279function stillOpen(): { prefix: string; all: Item[]; shown: Item[] }[] {
280  return STILL_OPEN.map((p) => {
281    const all =
282      p === 'Q' ? openQuestions(S.items, S.answered) : ofPrefix(p).filter((i) => !S.closed.has(i.code.toUpperCase()));
283    return { prefix: p, all, shown: all.slice(-STILL_OPEN_SHOWN) };
284  }).filter((g) => g.all.length > 0);
285}
286
287// A dismissed code is a question answered `x`, an item an `X` line dropped,
288// or a finding an erratum withdrew.
289function dismissed(i: Item): boolean {
290  const c = S.closed.get(i.code.toUpperCase());
291  return c !== undefined && (c.letter === 'x' || c.prefix === 'X' || i.prefix === 'F');
292}
293
294function isClosed(i: Item): boolean {
295  return S.closed.has(i.code.toUpperCase());
296}
297
298// A row's status glyph: a circle for an item still open or of a type that
299// never closes, a check for one answered or closed, a cross for one dismissed
300// or dropped, and a bang for an open line an erratum corrected without
301// restating it, since its title is the wrong version. Every row has one, and
302// the Status menu carries the key.
303function glyph(i: Item): string {
304  if (!isClosed(i)) return S.corrected.has(i.code.toUpperCase()) ? '!' : '○';
305  return dismissed(i) ? '✗' : '✓';
306}
307
308// A check is green, a cross red, a bang yellow, and a circle grey, in a drawer
309// row, a band popup row, a card header, and the key.
310function paint(Text: ReturnType<EngineInterface['ui']['resolve']>['Text'], g: string) {
311  return g === '✓' ? <Text color="success">{g}</Text> : g === '✗' ? <Text color="error">{g}</Text> : g === '!' ? <Text color="warning">{g}</Text> : <Text dimColor>{g}</Text>;
312}
313
314
315// A card's closing line names how the item ended, with a verb for each way
316// and the line that did it: a question Answered, Settled, or Dismissed, owed
317// work Done, a block Cleared, a risk Retired, a caveat Lifted, anything an
318// exclusion Dropped, and a finding Withdrawn. The mark and verb are the head,
319// which alone takes the colour; the tail is information.
320const VERB: Record<string, string> = { Q: 'Settled by', NA: 'Done in', MV: 'Done in', W: 'Done in', B: 'Cleared by', R: 'Retired by', C: 'Lifted by' };
321
322function closing(i: Item): { head: string; tail: string } | null {
323  const c = S.closed.get(i.code.toUpperCase());
324  if (!c) return null;
325  const by = c.by ? `${c.by}${c.title ? `: ${c.title}` : ''}` : '';
326  if (i.prefix === 'F') return { head: '✗ Withdrawn', tail: c.by ? ` by ${c.by}` : '' };
327  if (c.letter === 'x') return { head: '✗ Dismissed', tail: '' };
328  if (S.answered.has(i.code.toUpperCase())) return { head: '✓ Answered', tail: `${c.letter ? ` ${c.letter}` : ''}${by ? ` · in ${by}` : ''}` };
329  if (c.prefix === 'X') return { head: '✗ Dropped by', tail: ` ${by}` };
330  return { head: `✓ ${VERB[i.prefix] ?? 'Resolved by'}`, tail: ` ${by}` };
331}
332
333// The closing line as one Text: the head in green or red, the tail plain.
334function closingLine(Text: ReturnType<EngineInterface['ui']['resolve']>['Text'], i: Item, key?: string) {
335  const c = closing(i);
336  if (!c) return null;
337  return (
338    <Text key={key} wrap="wrap">
339      <Text color={dismissed(i) ? 'error' : 'success'}>{c.head}</Text>
340      {c.tail}
341    </Text>
342  );
343}
344
345// The card of a line an erratum corrected without restating it leads with the
346// erratum, because the title above it is the version the erratum replaced.
347function correctionLine(Text: ReturnType<EngineInterface['ui']['resolve']>['Text'], i: Item, key?: string) {
348  const e = S.corrected.get(i.code.toUpperCase());
349  if (!e) return null;
350  return (
351    <Text key={key} wrap="wrap">
352      <Text color="warning">! Corrected by</Text>
353      {` ${e.code}${e.summary ? `: ${e.summary}` : ''}`}
354    </Text>
355  );
356}
357
358// A finding closes only when withdrawn, so its card lists the codes that cite it.
359function backlinks(i: Item): string {
360  const by = i.prefix === 'F' ? (S.citedBy.get(i.code.toUpperCase()) ?? []) : [];
361  return by.length > 0 ? `Cited by ${by.map((j) => j.code).join(' ')}` : '';
362}
363
364function hintFor(code: string): string {
365  return `Ex: ${code} a or ${code} z <custom>`;
366}
367
368function ofPrefix(p: string): Item[] {
369  return S.items.filter((i) => i.prefix === p);
370}
371
372function haystack(i: Item): string {
373  return [i.code, nameOf(i), i.title, i.summary, ...i.options.map((o) => `${o.key}. ${o.text}`), i.rec]
374    .join('\n')
375    .toLowerCase();
376}
377
378// Status open keeps the types that can close and are not yet closed, and Status
379// resolved the closed ones, done and dropped alike.
380function shows(i: Item, show: Show = S.show): boolean {
381  if (show === 'open') return CLOSING.has(i.prefix) && !isClosed(i);
382  if (show === 'resolved') return isClosed(i);
383  return true;
384}
385
386// A query spelled as a code ("F1") finds that code alone, so F10 to F19 stay
387// out; anything else searches the text. A code asked for by name, the
388// selected one, or the still open list shows whatever Status says, since the
389// person went to it directly. Open items come first within each type, in
390// ledger order otherwise. A heading asks with Status set to all.
391function visible(show: Show = S.show): Item[] {
392  const q = S.query.trim().toLowerCase();
393  const exact = CODE_ONLY.test(q);
394  return S.items
395    .filter(
396      (i) =>
397        (S.prefix === 'all' || i.prefix === S.prefix) &&
398        (S.only.length === 0 || S.only.includes(i.code)) &&
399        (q === '' || (exact ? i.code.toLowerCase() === q : haystack(i).includes(q))) &&
400        (exact || S.only.length > 0 || i.code === S.selected || shows(i, show)),
401    )
402    .sort((a, b) => Number(isClosed(a)) - Number(isClosed(b)));
403}
404
405// How many lines a wrapping row takes: `widths` laid left to right, `gap`
406// apart, in `room` cells.
407function lines(widths: number[], room: number, gap: number): number {
408  let n = 1;
409  let used = 0;
410  for (const w of widths) {
411    if (used > 0 && used + gap + w > room) {
412      n += 1;
413      used = w;
414    } else used += (used > 0 ? gap : 0) + w;
415  }
416  return n;
417}
418
419async function openPane($: EngineInterface, query?: string, only: string[] = []): Promise<string> {
420  if (query !== undefined) {
421    S.query = query;
422    S.prefix = 'all';
423    S.selected = CODE_ONLY.test(query.trim()) ? query.trim().toUpperCase() : '';
424  }
425  // Every open starts in the short view with the filter list closed, except
426  // the still open codes, which open in full so their options show. Status
427  // keeps its last setting.
428  S.only = only;
429  S.full = only.length > 0;
430  S.filterOpen = S.statusOpen = false;
431  const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true });
432  S.paneOpen = opened.isPlaced;
433  return opened.isPlaced ? '' : `Katharsis drawer is waiting: ${opened.reason}`;
434}
435
436// The reply's text with every code on record turned into a link, skipping
437// fenced blocks and inline code spans, where a link would draw literally.
438export function linkify(text: string, items: Item[]): { text: string; hrefs: string[] } {
439  const hrefs = new Set<string>();
440  const byCode = new Map(items.map((i) => [i.code.toUpperCase(), i]));
441  const link = (seg: string) =>
442    seg.replace(CODE_RE, (c) => {
443      const item = byCode.get(c.toUpperCase());
444      if (!item) return c;
445      const href = `${LINK_BASE}${c}/${nameOf(item).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
446      hrefs.add(href);
447      return `[${c}](${href})`;
448    });
449  let inFence = false;
450  const out = text.split('\n').map((line) => {
451    if (/^\s*(```|~~~)/.test(line)) {
452      inFence = !inFence;
453      return line;
454    }
455    if (inFence) return line;
456    return line
457      .split(/(`[^`]*`)/)
458      .map((seg) => (seg.startsWith('`') && seg.endsWith('`') && seg.length > 1 ? seg : link(seg)))
459      .join('');
460  });
461  return { text: out.join('\n'), hrefs: [...hrefs] };
462}
463
464function codeOfHref(href: string): string {
465  return href.startsWith(LINK_BASE) ? (href.slice(LINK_BASE.length).split('/')[0] ?? '') : '';
466}
467
468// The session record's two late fields (session.ts). They live here because
469// the engine takes one hook per event from the plugin, and the drawer's
470// hooks below already hold classic.Stop and turn.complete.
471async function dataDir($: EngineInterface): Promise<string> {
472  const home = (await $.env.get('HOME')) ?? '';
473  return (await $.env.get('KATHARSIS_DATA')) ?? `${home}/.claude/katharsis-data`;
474}
475
476// The transcript's path arrives only with a classic hook's input.
477async function noteTranscript($: EngineInterface, sid: string, path: string): Promise<void> {
478  if (!sid || !path) return;
479  const data = await dataDir($);
480  const rec = await readRecord(engineIo($), data, sid);
481  const next = rec && withTranscript(rec, path, await $.fs.exists(path));
482  if (next) await $.fs.write(recordPath(data, sid), `${JSON.stringify(next, null, 2)}\n`);
483}
484
485async function titleSession($: EngineInterface): Promise<void> {
486  const sid = await $.session.id();
487  if (!sid) return;
488  const data = await dataDir($);
489  const io = engineIo($);
490  const rec = await readRecord(io, data, sid);
491  if (!rec || !wantsTitle(rec, await $.session.turns())) return;
492  const r = await $.model.fork({ prompt: TITLE_PROMPT });
493  const title = r.isAnswered ? cleanTitle(r.text) : '';
494  // Re-read, since the next prompt may have touched the record meanwhile.
495  const fresh = title ? await readRecord(io, data, sid) : null;
496  if (fresh) await $.fs.write(recordPath(data, sid), `${JSON.stringify({ ...fresh, title, titleSource: 'katharsis' }, null, 2)}\n`);
497}
498
499export function registerDrawer(on: On, level = ''): void {
500  // A register() call starts clean: a hot reload re-runs it, and so does a
501  // change to the autonomy level in /config.
502  Object.assign(S, fresh(), { level });
503
504  // After the Stop command hooks (ledger-stop.sh) have written this turn's rows.
505  on('classic.Stop', async ($, e, next) => {
506    const r = await next(e);
507    await noteTranscript($, e.session_id, e.transcript_path).catch(() => undefined);
508    await redrawFresh($);
509    return r;
510  }).catch(($, e, next) => next(e));
511
512  // A managed plugin can route classic.* past the user tier (sec-default
513  // does), so the Stop hook above may never run. turn.complete can fire
514  // before ledger-stop.sh writes, so it reloads now and twice more after.
515  on('turn.complete', async ($, e, next) => {
516    const r = await next(e);
517    if (e.agentId) return r;
518    // After the reply is out, so the title's fork never delays it.
519    if (!e.isAborted) void titleSession($).catch(() => undefined);
520    S.lastAnswer = typeof e.answer === 'string' ? e.answer : '';
521    await redrawFresh($);
522    $.clock.after(1500, () => void redrawFresh($));
523    $.clock.after(5000, () => void redrawFresh($));
524    return r;
525  }).catch(($, e, next) => next(e));
526
527  // Focus moving to anything but the menu or its button closes the menu: a
528  // click on a row, the search field, or another button.
529  on('ui.focus', { requestId: PANE }, ($, e, next) => {
530    if (S.filterOpen && e.element !== 'filter' && !e.element?.startsWith('filter-')) {
531      S.filterOpen = false;
532      $.ui.invalidate('ui.render');
533    }
534    if (S.statusOpen && e.element !== 'show' && !e.element?.startsWith('status-')) {
535      S.statusOpen = false;
536      $.ui.invalidate('ui.render');
537    }
538    return next(e);
539  }).catch(($, e, next) => next(e));
540
541  on('ui.close', async ($, e, next) => {
542    const r = await next(e);
543    if (e.id === PANE) {
544      S.paneOpen = false;
545      $.ui.invalidate('ui.render');
546    }
547    return r;
548  }).catch(($, e, next) => next(e));
549
550  on('session.start', async ($, e, next) => {
551    await refresh($);
552    return next(e);
553  }).catch(($, e, next) => next(e));
554
555  // /clear goes on under a new session id with no session.start, so the
556  // cache still holds the old session's items. Emptying it makes the band's
557  // next drawing load the new one. The command stays registered.
558  on('session.end', async ($, e, next) => {
559    const r = await next(e);
560    if (e.reason === 'clear') {
561      Object.assign(S, fresh(), { commandRegistered: S.commandRegistered, show: S.show, level: S.level });
562      $.ui.invalidate('ui.render');
563    }
564    return r;
565  }).catch(($, e, next) => next(e));
566
567  on('command.run', { command: PANE }, async ($, e) => {
568    await refresh($);
569    if (!S.active) return { text: 'Katharsis is not active in this session.' };
570    const text = await openPane($, e.args.trim());
571    return text ? { text } : {};
572  }).catch(($, e, next) => next(e));
573
574  // The band: the title in teal, an open button, the command's name, then
575  // one label per type present. A Button's label takes no color, so the
576  // title is text and the open button sits beside it. Hovering a label
577  // reveals that type's latest titles above the row. The reveal sits above
578  // the row so the row stays under the pointer while the band grows, and it
579  // joins the label's hover group, so the pointer can move up into it and
580  // press a title or the list-all button.
581  //
582  // Every reveal is one fixed height, so moving between labels never resizes
583  // the band, and the band never grows past maxRows, where the engine would
584  // make it scroll. Each label's box holds the pipe after it, so crossing a
585  // pipe keeps the pointer inside a group and the reveal never blinks.
586  // While the pane is open the reveals are left out: the pointer's last hover
587  // stays lit under the docked pane, and the surface left it half drawn.
588  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
589    if (e.props.hasSurvey) return next(e);
590    if (!S.loaded) await refresh($);
591    if (!S.active) return next(e);
592    // Another plugin's band draws beneath this one rather than being replaced.
593    const below = await next(e);
594    const { Box, Text, Button } = $.ui.resolve(e);
595    const present = byCode(prefixes());
596    const most = Math.max(0, ...present.map((p) => ofPrefix(p).length));
597    // Border 2, header 1, band row 1; what is left holds titles, at most 10.
598    const listRows = Math.max(1, Math.min(REVEAL_MAX, most, e.props.maxRows - 4));
599    const revealHeight = listRows + 3;
600    const titleWidth = Math.max(10, e.props.bodyColumns - 4);
601    // The hint goes when the row would not fit, as beside a docked pane: a row
602    // too wide shrinks its Texts to nothing rather than cutting the tail.
603    const labels = present.map((p) => `${p}:${ofPrefix(p).length}`).join('|');
604    const hint = `▸ ${TITLE} · open | use /${PANE} · ${labels}`.length <= e.props.bodyColumns ? `| use /${PANE} ·` : '·';
605    const openType = (p: string, code = '') => {
606      S.query = code;
607      S.selected = code;
608      S.prefix = p;
609      void openPane($).then(() => $.ui.invalidate('ui.render'));
610    };
611    const band = (
612      <Box flexDirection="column">
613        {(S.paneOpen ? [] : present).map((p) => {
614          const items = ofPrefix(p);
615          const shown = items.slice(-listRows);
616          // The code cell fits the widest code shown, so every title starts in one column.
617          const codeWidth = Math.max(0, ...shown.map((i) => i.code.length));
618          return (
619            <Box
620              key={`reveal-${p}`}
621              display="none"
622              hover={{ scope: `kband-${p}`, display: 'flex' }}
623              flexDirection="column"
624              height={revealHeight}
625              overflow="hidden"
626              borderStyle="round"
627              paddingX={1}
628            >
629              <Box key={`reveal-head-${p}`} flexDirection="row" gap={2}>
630                <Text bold>{`${groupName(p)} · ${items.length}`}</Text>
631                {items.length > shown.length ? <Text dimColor>{`latest ${shown.length}`}</Text> : null}
632                <Button key={`reveal-all-${p}`} label={`list all ${items.length} ▸`} plain onPress={() => openType(p)} />
633              </Box>
634              {shown.map((i) => (
635                // The row leads with the glyph its drawer row carries.
636                <Box key={`reveal-row-${i.code}`} flexDirection="row">
637                  <Box key={`reveal-glyph-${i.code}`} width={2} flexShrink={0}>{paint(Text, glyph(i))}</Box>
638                  <Button
639                    key={`reveal-${i.code}`}
640                    label={clip(`${i.code.padEnd(codeWidth)}  ${i.title}`, titleWidth - 2)}
641                    plain
642                    onPress={() => openType(p, i.code)}
643                  />
644                </Box>
645              ))}
646            </Box>
647          );
648        })}
649        <Box key="band-row" flexDirection="row" gap={1} height={1} overflow="hidden">
650          <Box key="band-head" flexDirection="row" gap={1} flexShrink={0}>
651            <Text color={TEAL}>{`▸ ${TITLE}`}</Text>
652            <Text dimColor>·</Text>
653            <Button
654              key="open"
655              label="open"
656              plain
657              hover={{ scope: 'kband-open', underline: true }}
658              // An empty query, as /kdrawer with no argument sends, so a code
659              // the band opened last time does not stay listed past Status.
660              onPress={() => {
661                void openPane($, '').then(() => refresh($)).then(() => $.ui.invalidate('ui.render'));
662              }}
663            />
664            <Text dimColor>{hint}</Text>
665          </Box>
666          {present.length === 0 ? <Text dimColor>no codes yet</Text> : null}
667          <Box key="band-labels" flexDirection="row">
668            {present.map((p, k) => (
669              <Box key={`label-${p}`} flexDirection="row" flexShrink={0} hover={{ scope: `kband-${p}` }}>
670                <Button
671                  key={`band-${p}`}
672                  label={`${p}:${ofPrefix(p).length}`}
673                  plain
674                  dimColor
675                  hover={{ scope: `kband-${p}`, bold: true }}
676                  onPress={() => openType(p)}
677                />
678                {k < present.length - 1 ? <Text dimColor>|</Text> : null}
679              </Box>
680            ))}
681          </Box>
682        </Box>
683      </Box>
684    );
685    return below ? <Box flexDirection="column">{band}{below}</Box> : band;
686  }).catch(($, e, next) => next(e));
687
688  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e, next) => {
689    if (!S.active) return next(e);
690    const T = $.ui.resolve(e);
691    const { Box, Text, Button } = T;
692    const redraw = () => $.ui.invalidate('ui.render');
693    const rows = visible();
694    const width = Math.max(20, e.props.bodyColumns);
695    const present = byName(prefixes());
696    const groups = byName([...new Set(rows.map((i) => i.prefix))]);
697    // The pane losing focus, a click in the transcript or the prompt, closes the menu.
698    if (!e.props.isFocused) S.filterOpen = S.statusOpen = false;
699    // Row 2 names the filter by its code when the full name would push the
700    // view toggle onto a line of its own, as in a docked pane. A Button draws
701    // as `[ label ]`, the controls sit 2 apart, and the row keeps 2 of padding.
702    // Past that the row wraps, and the menu drops below its last line.
703    const viewLabel = S.full ? 'Show short view' : 'Show full view';
704    // Status opens a menu of all, open, and resolved, with the key to the row
705    // glyphs below them. Clear leaves it alone: Status is a standing
706    // preference that outlives the pane, and Clear undoes one visit's search.
707    const showLabel = `Status: ${S.show} ${S.statusOpen ? '▴' : '▾'}`;
708    const named = S.only.length > 0 ? 'still open' : S.prefix === 'all' ? 'all types' : groupName(S.prefix);
709    const controls = (filter: string) => [filter.length + 4, showLabel.length + 4, 'Clear'.length + 4, viewLabel.length + 4];
710    const fits = lines(controls(`Filter: ${named} ▾`), width - 2, 2) === 1;
711    const filterLabel = `Filter: ${fits ? named : S.only.length > 0 ? 'open' : S.prefix} ${S.filterOpen ? '▴' : '▾'}`;
712    const menuTop = 1 + lines(controls(filterLabel), width - 2, 2);
713    const menu = [
714      { value: 'all', name: 'All types', n: S.items.length },
715      ...present.map((p) => ({ value: p, name: groupName(p), n: ofPrefix(p).length })),
716    ];
717    // An inline pane's frame fits the tree, which a placed menu adds nothing
718    // to, and stops at the rows the surface gave it. The tree grows to reach
719    // an open menu's foot, and a menu taller than the room splits into columns.
720    const inline = e.props.placement === 'inline';
721    // Columns stop at what the drawer's width holds whole, and a menu still
722    // taller than the room scrolls with the drawer.
723    const longest = Math.max(...menu.map((f) => `● ${f.name} ${f.n}`.length));
724    const room = inline ? Math.max(1, e.props.scroll.bodyRows - menuTop - 2) : menu.length;
725    const across = Math.min(Math.ceil(menu.length / room), Math.max(1, Math.floor((width - 2) / (longest + 2))));
726    const perColumn = Math.ceil(menu.length / across);
727    // Recounted, so a split the width capped never leaves its last column empty.
728    const columns = Math.ceil(menu.length / perColumn);
729    // The menu reaches the Clear button's right edge, wider only for a long name.
730    const reach = filterLabel.length + 4 + 2 + showLabel.length + 4 + 2 + 'Clear'.length + 4;
731    const menuWidth = Math.min(width, Math.max(reach, columns * longest + (columns - 1) * 2 + 4));
732    const cellWidth = Math.floor((menuWidth - 4 - (columns - 1) * 2) / columns);
733    // The Status menu drops below the Status button, or from the left edge
734    // when the button wrapped onto a line of its own.
735    const statusMenu = SHOWS.map((s) => ({
736      value: s,
737      n: S.items.filter((i) => (S.prefix === 'all' || i.prefix === S.prefix) && shows(i, s)).length,
738    }));
739    const legend = [
740      { g: '○', text: 'open, or never closes' },
741      { g: '✓', text: 'answered, settled, or done' },
742      { g: '✗', text: 'dismissed, dropped, or withdrawn' },
743      { g: '!', text: 'corrected, title not restated' },
744    ];
745    const statusWidth = Math.min(width, Math.max(...legend.map((l) => l.text.length + 2), ...statusMenu.map((f) => `● ${f.value} ${f.n}`.length)) + 4);
746    // A menu wider than the room right of the button shifts left to stay inside the drawer.
747    // A legend line too long for the menu wraps by word, and each wrap adds a row.
748    const legendRows = legend.reduce((n, l) => n + lines(l.text.split(' ').map((w) => w.length), statusWidth - 6, 1), 0);
749    const menuFoot = menuTop + (S.filterOpen ? perColumn + 2 : S.statusOpen ? statusMenu.length + legendRows + 3 : 0);
750    const statusLeft = Math.max(0, Math.min(width - statusWidth, lines([filterLabel.length + 4, showLabel.length + 4], width - 2, 2) === 1 ? filterLabel.length + 4 + 2 : 0));
751
752    const body = (i: Item) => [
753      closingLine(Text, i, `closed-${i.code}`),
754      correctionLine(Text, i, `corrected-${i.code}`),
755      backlinks(i) ? <Text key={`cited-${i.code}`} wrap="wrap" dimColor>{backlinks(i)}</Text> : null,
756      i.summary ? <Text key={`sum-${i.code}`} wrap="wrap" dimColor={i.prefix === 'Q'}>{i.summary}</Text> : null,
757      ...i.options.map((o) => <Text key={`opt-${i.code}-${o.key}`} wrap="wrap">{`  ${o.key}. ${o.text}`}</Text>),
758      i.rec ? <Text key={`rec-${i.code}`} wrap="wrap"><Text bold>Recommended:</Text>{` ${i.rec}`}</Text> : null,
759    ];
760
761    // A row is a table line: the code, the status glyph, and the title, which
762    // wraps in the cells left. Every row keeps the glyph cell, blank or not, so
763    // every title starts in one column. The code cell fits the widest code
764    // shown and its marker.
765    const codeWidth = Math.max(0, ...rows.map((i) => i.code.length)) + 4;
766    // A heading counts its type under the search and filters but not Status,
767    // and for a type that closes, how many are open, so Status open never
768    // reads "1 open of 1".
769    const every = visible('all');
770    const heading = (p: string) => {
771      const of = every.filter((i) => i.prefix === p);
772      const open = of.filter((i) => !isClosed(i)).length;
773      return CLOSING.has(p) ? `${groupName(p)} · ${open} open of ${of.length}` : `${groupName(p)} · ${of.length}`;
774    };
775    // The code is a button: pressing it opens the item as a card beneath the
776    // row, in a frame, and pressing it again closes the card.
777    const entry = (i: Item) => {
778      const open = S.selected === i.code;
779      return (
780        <Box key={`row-${i.code}`} flexDirection="column" marginTop={S.full ? 1 : 0}>
781          <Box key={`line-${i.code}`} flexDirection="row" alignItems="flex-start">
782            <Box key={`cell-code-${i.code}`} width={codeWidth} flexShrink={0}>
783              <Button
784                key={`pick-${i.code}`}
785                label={`${open ? '▾' : '▸'} ${i.code}`}
786                plain
787                hover={{ scope: `kref-${i.code}`, inverse: true }}
788                onPress={() => {
789                  S.selected = open ? '' : i.code;
790                  S.filterOpen = S.statusOpen = false;
791                  redraw();
792                }}
793              />
794            </Box>
795            <Box key={`cell-status-${i.code}`} width={2} flexShrink={0}>
796              {paint(Text, glyph(i))}
797            </Box>
798            <Box key={`cell-title-${i.code}`} flexGrow={1} flexShrink={1} minWidth={0}>
799              <Text wrap="wrap">{i.title}</Text>
800            </Box>
801          </Box>
802          {open ? (
803            <Box
804              key={`card-${i.code}`}
805              flexDirection="column"
806              borderStyle="round"
807              backgroundColor="userMessageBackground"
808              paddingX={1}
809            >
810              <Text>{paint(Text, glyph(i))}<Text color="cyan">{` ${i.code} · ${nameOf(i)}`}</Text></Text>
811              {body(i)}
812            </Box>
813          ) : S.full ? (
814            // The body starts under the title, past the code and glyph cells.
815            <Box key={`body-${i.code}`} flexDirection="column" paddingLeft={codeWidth + 2}>
816              {body(i)}
817            </Box>
818          ) : null}
819        </Box>
820      );
821    };
822
823    return (
824      <Box flexDirection="column" width={width} minHeight={inline ? menuFoot : undefined}>
825        <Box key="top" flexDirection="row">
826          {'Input' in T ? (
827            <Box key="q-box" flexGrow={1} flexShrink={1}>
828              <T.Input
829                key="q"
830                label="Search"
831                placeholder="code, title, body, option"
832                value={S.query}
833                submitLabel=""
834                autoFocus
835                onInput={(v) => {
836                  S.query = v;
837                  redraw();
838                }}
839                onSubmit={(v) => {
840                  S.query = v;
841                  redraw();
842                }}
843              />
844            </Box>
845          ) : null}
846        </Box>
847        <Box key="filters" flexDirection="row" flexWrap="wrap" columnGap={2} paddingRight={2}>
848          <Button
849            key="filter"
850            label={filterLabel}
851            hotkey="f"
852            onPress={() => {
853              S.filterOpen = !S.filterOpen;
854              S.statusOpen = false;
855              redraw();
856            }}
857          />
858          <Button
859            key="show"
860            label={showLabel}
861            hotkey="s"
862            onPress={() => {
863              S.statusOpen = !S.statusOpen;
864              S.filterOpen = false;
865              redraw();
866            }}
867          />
868          <Button
869            key="clear"
870            label="Clear"
871            onPress={() => {
872              S.query = '';
873              S.prefix = 'all';
874              S.only = [];
875              S.selected = '';
876              S.filterOpen = S.statusOpen = false;
877              redraw();
878            }}
879          />
880          <Box key="view-box" flexGrow={1} flexDirection="row" justifyContent="flex-end">
881            <Button
882              key="view"
883              label={viewLabel}
884              hotkey="v"
885              onPress={() => {
886                S.full = !S.full;
887                S.filterOpen = S.statusOpen = false;
888                redraw();
889              }}
890            />
891          </Box>
892        </Box>
893        <Text key="count" dimColor>{`${rows.length} of ${S.items.length} items${S.full ? '' : ' · titles only, press a code to open it'}`}</Text>
894        {rows.length === 0 ? <Text dimColor>Nothing matches.</Text> : null}
895        {groups.map((p) => (
896          <Box key={`group-${p}`} flexDirection="column" marginTop={1}>
897            <Text bold color="cyan">{heading(p)}</Text>
898            {rows.filter((i) => i.prefix === p).map(entry)}
899          </Box>
900        ))}
901        {S.filterOpen ? (
902          <Box
903            key="filter-list"
904            position="absolute"
905            top={menuTop}
906            left={0}
907            width={menuWidth}
908            flexDirection="row"
909            columnGap={2}
910            borderStyle="round"
911            backgroundColor="userMessageBackground"
912            paddingX={1}
913          >
914            {Array.from({ length: columns }, (_, c) => (
915              <Box key={`filter-column-${c}`} flexDirection="column" width={cellWidth}>
916                {menu.slice(c * perColumn, (c + 1) * perColumn).map((f) => (
917                  <Button
918                    key={`filter-${f.value}`}
919                    label={menuRow(`${S.prefix === f.value && S.only.length === 0 ? '●' : ' '} ${f.name}`, String(f.n), cellWidth)}
920                    plain
921                    onPress={() => {
922                      S.prefix = f.value;
923                      S.only = [];
924                      S.filterOpen = S.statusOpen = false;
925                      redraw();
926                    }}
927                  />
928                ))}
929              </Box>
930            ))}
931          </Box>
932        ) : null}
933        {S.statusOpen ? (
934          <Box
935            key="status-list"
936            position="absolute"
937            top={menuTop}
938            left={statusLeft}
939            width={statusWidth}
940            flexDirection="column"
941            borderStyle="round"
942            backgroundColor="userMessageBackground"
943            paddingX={1}
944          >
945            {statusMenu.map((f) => (
946              <Button
947                key={`status-${f.value}`}
948                label={menuRow(`${S.show === f.value ? '●' : ' '} ${f.value}`, String(f.n), statusWidth - 4)}
949                plain
950                onPress={() => {
951                  S.show = f.value;
952                  S.only = [];
953                  S.statusOpen = false;
954                  redraw();
955                }}
956              />
957            ))}
958            <Box key="status-legend" flexDirection="column" marginTop={1}>
959              {legend.map((l) => (
960                <Box key={`legend-${l.g}`} flexDirection="row">
961                  <Box width={2} flexShrink={0}>{paint(Text, l.g)}</Box>
962                  <Text dimColor wrap="wrap">{l.text}</Text>
963                </Box>
964              ))}
965            </Box>
966          </Box>
967        ) : null}
968      </Box>
969    );
970  }).catch(($, e, next) => next(e));
971
972  // A reply that cites codes on record: each code becomes a link that opens
973  // the pane at it. Under the reply, a row of chips names the codes it cites
974  // other than questions, and under the latest reply a second row names what
975  // is still open, with a button that opens it all in the pane in full. Each
976  // chip carries a hover card. Only Claude Code's own drawing is replaced: a
977  // drawing another plugin beneath made stands, as does the engine's for a
978  // reply too long for a Markdown element, and either gets the rows alone.
979  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
980    if (!S.active) return next(e);
981    const byCodeMap = new Map(S.items.map((i) => [i.code.toUpperCase(), i]));
982    const cited = [...new Set(e.props.text.match(CODE_RE) ?? [])]
983      .map((c) => byCodeMap.get(c.toUpperCase()))
984      .filter((i): i is Item => i !== undefined);
985    // The block that ends the last finished reply is the latest one.
986    const latest = S.lastAnswer.trim() !== '' && e.props.text.trim() !== '' && S.lastAnswer.trimEnd().endsWith(e.props.text.trim());
987    const groups = latest ? stillOpen() : [];
988    const suggestion = latest ? S.suggestion : null;
989    if (cited.length === 0 && groups.length === 0 && !suggestion) return next(e);
990    const { Box, Text, Button, Markdown } = $.ui.resolve(e);
991    const cols = e.viewport?.columns ?? 80;
992    const cardWidth = Math.max(30, Math.min(72, cols - 6));
993    // Where each chip starts, from the row's own wrapping, so its card can
994    // move left of the chip far enough to end inside the screen.
995    const starts = (widths: number[]) => {
996      let x = 0;
997      return widths.map((w, k) => {
998        if (k > 0 && x + 1 + w > cols - 2) x = 0;
999        else if (k > 0) x += 1;
1000        const at = x;
1001        x += w;
1002        return 2 + at;
1003      });
1004    };
1005    const linked = linkify(e.props.text, S.items);
1006    // The pane opens before the refresh: an open that follows an await no
1007    // longer counts as the person's ask, and waits undrawn below 144 columns.
1008    const openAt = (code: string, only: string[] = []) => {
1009      void openPane($, code, only).then(() => refresh($)).then(() => $.ui.invalidate('ui.render'));
1010    };
1011    const beneath = await next(e);
1012    const reply =
1013      beneath.type === 'engine' && linked.text.length <= MARKDOWN_MAX ? (
1014        <Box key="reply" flexDirection="row">
1015          <Box width={2} flexShrink={0}>
1016            <Text>{e.props.isFirstOfReply ? '●' : ' '}</Text>
1017          </Box>
1018          <Markdown
1019            key="reply-text"
1020            text={linked.text}
1021            pressableLinks={linked.hrefs}
1022            onLinkPress={(l) => openAt(codeOfHref(l.href))}
1023          />
1024        </Box>
1025      ) : (
1026        beneath
1027      );
1028    const chip = (i: Item, at: number, hint = '') => (
1029      <Box key={`chip-${i.code}`}>
1030        <Button
1031          key={`chip-${i.code}`}
1032          label={i.code}
1033          plain
1034          dimColor
1035          hover={{ scope: `kref-${i.code}`, bold: true }}
1036          onPress={() => openAt(i.code)}
1037        />
1038        <Box
1039          position="absolute"
1040          bottom={1}
1041          left={Math.min(0, cols - cardWidth - at)}
1042          width={cardWidth}
1043          display="none"
1044          hover={{ display: 'flex' }}
1045          borderStyle="round"
1046          backgroundColor="userMessageBackground"
1047          paddingX={1}
1048          flexDirection="column"
1049        >
1050          <Text>{paint(Text, glyph(i))}<Text color="cyan">{` ${i.code} · ${nameOf(i)}`}</Text></Text>
1051          <Text bold wrap="wrap">{i.title}</Text>
1052          {closingLine(Text, i)}
1053          {correctionLine(Text, i)}
1054          {backlinks(i) ? <Text wrap="wrap" dimColor>{backlinks(i)}</Text> : null}
1055          {i.summary ? <Text wrap="wrap" dimColor={i.prefix === 'Q'}>{i.summary}</Text> : null}
1056          {i.options.map((o) => (
1057            <Text wrap="wrap">{`  ${o.key}. ${o.text}`}</Text>
1058          ))}
1059          {i.rec ? <Text wrap="wrap"><Text bold>Recommended:</Text>{` ${i.rec}`}</Text> : null}
1060          {hint ? <Text dimColor>{hint}</Text> : null}
1061          <Text dimColor>{`click ${i.code} to open it in /${PANE}`}</Text>
1062        </Box>
1063      </Box>
1064    );
1065    const codes = codeOrder(cited.filter((i) => i.prefix !== 'Q'));
1066    const codeAt = starts(['Codes this turn:'.length, ...codes.map((i) => i.code.length)]).slice(1);
1067    const openRow: { item?: Item; text: string; key?: string }[] = groups.flatMap((g, k) => [
1068      ...(k > 0 ? [{ text: '·', key: `sep-${g.prefix}` }] : []),
1069      ...g.shown.map((i) => ({ item: i, text: i.code })),
1070      ...(g.all.length > g.shown.length ? [{ text: `+${g.all.length - g.shown.length}`, key: `more-${g.prefix}` }] : []),
1071    ]);
1072    const openStarts = starts(['Still open:'.length, ...openRow.map((c) => c.text.length)]).slice(1);
1073    return (
1074      <Box flexDirection="column">
1075        {reply}
1076        {codes.length > 0 ? (
1077          <Box key="chips" flexDirection="row" gap={1} flexWrap="wrap" marginLeft={2}>
1078            <Text dimColor>Codes this turn:</Text>
1079            {codes.map((i, k) => chip(i, codeAt[k]!))}
1080          </Box>
1081        ) : null}
1082        {groups.length > 0 ? (
1083          <Box key="still-open" flexDirection="row" gap={1} flexWrap="wrap" marginLeft={2}>
1084            <Text dimColor>Still open:</Text>
1085            {openRow.map((c, k) =>
1086              c.item ? (
1087                chip(c.item, openStarts[k]!, c.item.prefix === 'Q' ? hintFor(c.item.code) : '')
1088              ) : (
1089                <Text key={`still-open-${c.key}`} dimColor>{c.text}</Text>
1090              ),
1091            )}
1092            {S.hint && groups[0]!.prefix === 'Q' ? (
1093              <Text key="still-open-hint" dimColor>{`· ${hintFor(groups[0]!.shown[0]!.code)}`}</Text>
1094            ) : null}
1095            <Button
1096              key="still-open-all"
1097              label="show all ▸"
1098              plain
1099              hover={{ scope: 'kopen-all', underline: true }}
1100              onPress={() => openAt('', groups.flatMap((g) => g.all.map((i) => i.code)))}
1101            />
1102          </Box>
1103        ) : null}
1104        {suggestion ? (
1105          <Box key="suggest" flexDirection="column" alignItems="flex-start" marginLeft={2}>
1106            <Text dimColor wrap="wrap">{`You took the recommendation on ${suggestion.took} of the ${suggestion.answered} questions you answered that carried one (${Math.round((100 * suggestion.took) / suggestion.answered)}%). The standard autonomy level may suit you: search /config for autonomy.`}</Text>
1107            <Button
1108              key="suggest-dismiss"
1109              label="dismiss"
1110              plain
1111              hover={{ scope: 'ksuggest-dismiss', underline: true }}
1112              onPress={() => {
1113                S.suggestion = null;
1114                $.ui.invalidate('ui.render');
1115                void dataDir($)
1116                  .then((d) => $.fs.write(`${d}/${SUGGEST_DISMISSED}`, `${new Date().toISOString()}\n`))
1117                  .catch(() => undefined);
1118              }}
1119            />
1120          </Box>
1121        ) : null}
1122      </Box>
1123    );
1124  }).catch(($, e, next) => next(e));
1125}
1126
hooks/ledger.ts 327 lines
1// ledger.ts: the one reader of the ledger's format. The drawer and the prompt
2// hook import it inside the engine, and a CLI under node can import it too,
3// so it holds no JSX, no enums and no engine calls, and every import it ever
4// takes spells its extension. Files arrive through an Io the caller builds on
5// whatever filesystem its runtime has.
6//
7// A handoff chain is one numbering space: ledger/chains/<sid> holds the
8// parent session's ID, and a session's thread is itself plus every ancestor.
9// A later record for a code supersedes an earlier one anywhere in the thread.
10
11export type Io = {
12  // The file's text, or null when it does not exist.
13  read(path: string): Promise<string | null>;
14  // The directory's entries, or none when it does not exist.
15  list(dir: string): Promise<{ name: string; kind: string }[]>;
16};
17
18export type Option = { key: string; text: string };
19export type Item = {
20  code: string;
21  prefix: string;
22  n: number;
23  known: boolean;
24  ts: string;
25  title: string;
26  summary: string;
27  options: Option[];
28  rec: string;
29  // The session that wrote the row, and the heading it sat under.
30  session: string;
31  section: string;
32  // The row's place in the files read, so items stamped in the same second
33  // keep the order the reply wrote them in.
34  seq: number;
35};
36
37// The stock prefixes in the order the next-free line lists them. D is
38// retired but still appears in older ledgers.
39export const ORDER = ['F', 'D', 'A', 'R', 'C', 'AT', 'V', 'NA', 'B', 'MV', 'W', 'X', 'S', 'T-O', 'E', 'Q'];
40
41const alpha = (a: string, b: string) => a.localeCompare(b, 'en');
42
43// Codes in a row, by type then number: A1, AT2, C1, F3, F10.
44export function codeOrder<T extends { prefix: string; n: number }>(xs: T[]): T[] {
45  return [...xs].sort((a, b) => alpha(a.prefix, b.prefix) || a.n - b.n);
46}
47
48// The session and every ancestor its chain file names, newest first.
49export async function thread(io: Io, data: string, sid: string): Promise<string[]> {
50  const ids: string[] = [];
51  let cur = sid;
52  while (cur && !ids.includes(cur) && ids.length < 20) {
53    ids.push(cur);
54    const link = await io.read(`${data}/ledger/chains/${cur}`);
55    if (link === null) break;
56    cur = link.trim();
57    if (!/^[\w-]+$/.test(cur)) break; // a link names a session, never a path
58  }
59  return ids;
60}
61
62// The text of every <id>.jsonl for the thread's ids, oldest session first, in
63// one directory or in each project directory under it. A session can span
64// two project directories, since rows written before the Stop hook keyed the
65// project on the transcript's directory sit under whichever cwd it saw.
66export async function threadTexts(io: Io, dir: string, ids: string[], nested: boolean): Promise<string[]> {
67  const dirs = nested
68    ? (await io.list(dir)).filter((d) => d.kind === 'dir' && d.name !== 'chains').map((d) => `${dir}/${d.name}`)
69    : [dir];
70  const texts: string[] = [];
71  for (const id of [...ids].reverse()) {
72    for (const d of dirs) {
73      const text = await io.read(`${d}/${id}.jsonl`);
74      if (text !== null) texts.push(text);
75    }
76  }
77  return texts;
78}
79
80function toItem(r: Record<string, unknown>): Item | undefined {
81  // A code and a timestamp are printed bare, so a row whose code or ts holds
82  // anything else, such as a terminal escape, is dropped or loses its ts.
83  if (typeof r.code !== 'string' || !/^[A-Z][A-Z-]*\d+$/i.test(r.code)) return undefined;
84  const options = Array.isArray(r.options)
85    ? r.options.map((o: Record<string, unknown>) => ({ key: String(o?.key ?? ''), text: String(o?.text ?? '') }))
86    : [];
87  return {
88    code: r.code,
89    prefix: String(r.prefix ?? ''),
90    n: Number(r.n ?? 0) || 0,
91    known: Boolean(r.known),
92    ts: typeof r.ts === 'string' && /^[\d:.TZ+-]+$/.test(r.ts) ? r.ts : '',
93    title: String(r.title ?? ''),
94    summary: String(r.summary ?? ''),
95    options,
96    rec: String(r.rec ?? ''),
97    session: String(r.session_id ?? ''),
98    section: String(r.section ?? ''),
99    seq: 0,
100  };
101}
102
103// The ledger files' rows as items: a later record for a code supersedes an
104// earlier one, and equal stamps fall to file order.
105export function itemsOf(texts: string[]): Item[] {
106  const latest = new Map<string, Item>();
107  let seq = 0;
108  for (const text of texts) {
109    for (const line of text.split('\n')) {
110      if (line.length > 1_000_000) continue; // a pathological row, never a real one
111      let item: Item | undefined;
112      try {
113        item = toItem(JSON.parse(line) as Record<string, unknown>);
114      } catch {
115        continue; // a blank or partial line
116      }
117      if (!item) continue;
118      item.seq = seq++;
119      const key = item.code.toUpperCase();
120      const old = latest.get(key);
121      if (!old || item.ts >= old.ts) latest.set(key, item);
122    }
123  }
124  return codeOrder([...latest.values()]);
125}
126
127// Every item in the session's thread.
128export async function threadItems(io: Io, data: string, sid: string): Promise<Item[]> {
129  return itemsOf(await threadTexts(io, `${data}/ledger`, await thread(io, data, sid), true));
130}
131
132// The line that tells the model where numbering resumes: the next free
133// number per prefix, stock prefixes in ORDER and invented ones after them.
134// Empty when the thread has no items.
135export function nextFree(items: Item[]): string {
136  const top = new Map<string, number>();
137  for (const i of items) {
138    if (i.prefix) top.set(i.prefix, Math.max(top.get(i.prefix) ?? 0, i.n));
139  }
140  if (top.size === 0) return '';
141  const keys = [
142    ...ORDER.filter((p) => top.has(p)),
143    ...[...top.keys()].filter((p) => !ORDER.includes(p)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
144  ];
145  return `Katharsis codes continue, never restart. Next free: ${keys.map((p) => `${p}${(top.get(p) ?? 0) + 1}`).join('  ')}`;
146}
147
148// A session's record, katharsis-data/sessions/<id>.json. Only Katharsis's
149// hooks write it: the prompt hook creates and touches it, the Stop hook adds
150// the transcript, and turn.complete adds the title. kref reads it to name the
151// session. transcript is
152// the path Claude Code reported, and transcriptSeen says the file existed at
153// some Stop: a path never seen belongs to a session that saved nothing, and
154// a record with no path at all means the Stop hook never ran.
155export type Release = { version: string; commit?: string };
156export type SessionRecord = {
157  id: string;
158  parent?: string;
159  cwd: string;
160  branch?: string;
161  started: string;
162  updated: string;
163  title?: string;
164  titleSource?: string;
165  katharsis: Release[];
166  transcript?: string;
167  transcriptSeen?: boolean;
168};
169
170export function recordPath(data: string, sid: string): string {
171  return `${data}/sessions/${sid}.json`;
172}
173
174const RECORD_TEXT = ['parent', 'cwd', 'branch', 'started', 'updated', 'title', 'titleSource', 'transcript'];
175
176// The session's record, or null when it has none or the file is unreadable.
177// A text field holding anything but text is dropped, as if never written.
178export async function readRecord(io: Io, data: string, sid: string): Promise<SessionRecord | null> {
179  const text = await io.read(recordPath(data, sid));
180  if (text === null) return null;
181  try {
182    const r = JSON.parse(text) as Record<string, unknown>;
183    if (!r || typeof r !== 'object' || r.id !== sid) return null;
184    const out: Record<string, unknown> = { ...r, katharsis: Array.isArray(r.katharsis) ? r.katharsis : [] };
185    for (const k of RECORD_TEXT) if (k in out && typeof out[k] !== 'string') delete out[k];
186    return out as SessionRecord;
187  } catch {
188    return null;
189  }
190}
191
192// The section each stock prefix groups under.
193export const SECTIONS: Record<string, string> = {
194  F: 'Findings', D: 'Decisions', A: 'Assumptions', R: 'Risks', C: 'Caveats', AT: 'Actions Taken',
195  V: 'Verified', NA: 'Next Actions', B: 'Blocked', MV: 'Your Move', W: 'Waiting', X: 'Excluded',
196  S: 'State', 'T-O': 'Trade-offs', E: 'Errata', Q: 'Questions',
197};
198
199// The section an item groups under.
200export function sectionOf(i: Item): string {
201  return SECTIONS[i.prefix.toUpperCase()] ?? (i.section.trim() || 'Other codes');
202}
203
204// Items grouped by section: stock prefixes in ORDER, invented ones by the
205// heading they were written under, and questions last.
206export function sections(items: Item[]): { name: string; items: Item[] }[] {
207  const rank = (i: Item) => {
208    const p = i.prefix.toUpperCase();
209    if (p === 'Q') return 1000;
210    const k = ORDER.indexOf(p);
211    return k >= 0 ? k : 500;
212  };
213  const name = sectionOf;
214  const groups = new Map<string, { rank: number; items: Item[] }>();
215  for (const i of codeOrder(items)) {
216    const g = groups.get(name(i)) ?? { rank: rank(i), items: [] };
217    g.items.push(i);
218    groups.set(name(i), g);
219  }
220  return [...groups.entries()]
221    .sort(([a, x], [b, y]) => x.rank - y.rank || alpha(a, b))
222    .map(([n, g]) => ({ name: n, items: g.items }));
223}
224
225// Every session's ledger text, by session ID, across every project
226// directory.
227export async function ledgerTexts(io: Io, data: string): Promise<Map<string, string[]>> {
228  const out = new Map<string, string[]>();
229  const root = `${data}/ledger`;
230  for (const d of await io.list(root)) {
231    if (d.kind !== 'dir' || d.name === 'chains') continue;
232    for (const f of await io.list(`${root}/${d.name}`)) {
233      if (!f.name.endsWith('.jsonl')) continue;
234      const text = await io.read(`${root}/${d.name}/${f.name}`);
235      if (text === null) continue;
236      const id = f.name.slice(0, -'.jsonl'.length);
237      out.set(id, [...(out.get(id) ?? []), text]);
238    }
239  }
240  return out;
241}
242
243// What kref knows about a session, from its record or from Claude Code's
244// own files. updated is when it last prompted or replied, codes counts the
245// codes it defined itself.
246export type SessionInfo = {
247  id: string;
248  cwd?: string;
249  branch?: string;
250  title?: string;
251  titleSource?: string;
252  started?: string;
253  updated?: string;
254  codes: number;
255};
256
257export type Scope =
258  | { kind: 'all'; reason: '--all' }
259  | { kind: 'session'; reason: '--session' | 'CLAUDE_CODE_SESSION_ID' | 'cwd'; id: string; more: number }
260  | { kind: 'sessions'; reason: 'below-cwd'; sessions: SessionInfo[] }
261  | { kind: 'error'; code: 'not_found' | 'ambiguous_session' | 'usage'; message: string };
262
263export type ScopeInput = {
264  ref?: string; // --session
265  all?: boolean; // --all
266  env?: string; // CLAUDE_CODE_SESSION_ID
267  cwd: string;
268  home: string;
269  sessions: SessionInfo[];
270};
271
272export const SESSION_ID = /^[0-9a-f-]+$/i;
273export const LIST_CAP = 20;
274
275const newest = (a: SessionInfo, b: SessionInfo) => ((a.updated ?? '') < (b.updated ?? '') ? 1 : (a.updated ?? '') > (b.updated ?? '') ? -1 : 0);
276const trim = (p: string) => (p.length > 1 ? p.replace(/\/+$/, '') : p);
277
278// The scoping rules, first match wins: a flag, then the Claude Code session
279// kref runs inside, then the newest session that ran in exactly this folder
280// (never ~ or /), then a list of the newest sessions at or under it.
281export function scope(input: ScopeInput): Scope {
282  const sessions = [...input.sessions].sort(newest);
283  if (input.all) return { kind: 'all', reason: '--all' };
284  if (input.ref !== undefined) {
285    const ref = input.ref.trim();
286    if (ref === 'last') {
287      return sessions[0]
288        ? { kind: 'session', reason: '--session', id: sessions[0].id, more: 0 }
289        : { kind: 'error', code: 'not_found', message: 'the ledger has no sessions' };
290    }
291    if (!SESSION_ID.test(ref) || ref.length < 8) {
292      return { kind: 'error', code: 'usage', message: `--session takes a session ID, a prefix of 8 or more of its characters, or last` };
293    }
294    const hits = sessions.filter((s) => s.id.toLowerCase().startsWith(ref.toLowerCase()));
295    if (hits.length === 0) return { kind: 'error', code: 'not_found', message: `no session starts with ${ref}` };
296    if (hits.length > 1) return { kind: 'error', code: 'ambiguous_session', message: `${hits.length} sessions start with ${ref}` };
297    return { kind: 'session', reason: '--session', id: hits[0].id, more: 0 };
298  }
299  if (input.env && SESSION_ID.test(input.env)) {
300    return { kind: 'session', reason: 'CLAUDE_CODE_SESSION_ID', id: input.env, more: 0 };
301  }
302  const cwd = trim(input.cwd);
303  if (cwd !== trim(input.home) && cwd !== '/') {
304    const here = sessions.filter((s) => s.cwd !== undefined && trim(s.cwd) === cwd);
305    if (here.length > 0) return { kind: 'session', reason: 'cwd', id: here[0].id, more: here.length - 1 };
306  }
307  return { kind: 'sessions', reason: 'below-cwd', sessions: below(sessions, cwd).slice(0, LIST_CAP) };
308}
309
310// The sessions that ran at or under a folder, newest first.
311export function below(sessions: SessionInfo[], folder: string): SessionInfo[] {
312  const cwd = trim(folder);
313  return [...sessions].sort(newest).filter((s) => {
314    if (s.cwd === undefined) return false;
315    const c = trim(s.cwd);
316    return cwd === '/' || c === cwd || c.startsWith(`${cwd}/`);
317  });
318}
319
320// A literal, case-insensitive match on a code's title, body and question
321// options. No regex, so a pattern can't run away.
322export function search(items: Item[], text: string): Item[] {
323  const needle = text.toLowerCase();
324  const hit = (s: string) => s.toLowerCase().includes(needle);
325  return items.filter((i) => hit(i.title) || hit(i.summary) || i.options.some((o) => hit(o.text)));
326}
327
hooks/session.ts 90 lines
1// session.ts: how the session record changes, katharsis-data/sessions/<id>.json.
2// The engine loads one hooks module per plugin, takes one hook per event from
3// it, and never lets $ cross an import, so the hooks that write the record
4// live where their events already are: register.ts touches it on each
5// prompt, and drawer.tsx adds the transcript at Stop and the title at
6// turn.complete. This file holds what they share, with no engine calls, so
7// the CLI can import it under node.
8//
9// The record is created on the session's first Katharsis prompt and touched
10// on every one after, given the transcript's path at each Stop, and given a
11// title by a fork of the conversation after turn 3 and every 15 turns after
12// that. A session outside Katharsis gets no record.
13
14import type { Release, SessionRecord } from './ledger.ts';
15
16// The outputStyle values that mean Katharsis is on, by bare name and by the
17// plugin-qualified name the style picker writes.
18export const KATHARSIS_STYLES = new Set([
19  'Katharsis',
20  'katharsis:Katharsis',
21  'Katharsis coding',
22  'katharsis:Katharsis coding',
23]);
24
25export const TITLE_PROMPT =
26  'Name the work this session is doing in 3 to 6 words, as a gerund phrase such as "Researching the Katharsis CLI". Reply with the phrase alone: no classification, no punctuation at the end, no other text.';
27
28// The fork's reply as a title, or '' when it is not one: the first line,
29// without quotes, markdown or a closing period, at most 8 words.
30export function cleanTitle(text: string): string {
31  const line = text.split('\n').map((l) => l.trim()).find((l) => l) ?? '';
32  const t = line.replace(/^[#>*_`"'\s]+|[*_`"'.\s]+$/g, '').trim();
33  const words = t.split(/\s+/).filter(Boolean).length;
34  return words >= 1 && words <= 8 && t.length <= 80 ? t : '';
35}
36
37// Whether this turn asks the fork for a title: from turn 3 until one lands,
38// then every 15 turns so the title follows the work when it drifts.
39export function wantsTitle(rec: SessionRecord, turns: number): boolean {
40  return turns >= 3 && (!rec.title || (turns - 3) % 15 === 0);
41}
42
43// The running plugin's version from its plugin.json, and the commit Claude
44// Code installed it from when installed_plugins.json has an entry at this
45// root. A --plugin-dir checkout has no entry, so it gets the version alone.
46export function releaseOf(root: string, manifest: string | null, installed: string | null): Release | null {
47  let version = '';
48  try {
49    version = String(JSON.parse(manifest ?? '{}').version ?? '');
50  } catch {
51    return null;
52  }
53  if (!version) return null;
54  try {
55    const plugins = JSON.parse(installed ?? '{}').plugins ?? {};
56    const entries = Object.values(plugins).flat() as { installPath?: string; gitCommitSha?: string }[];
57    const commit = entries.find((p) => p.installPath === root)?.gitCommitSha;
58    if (commit) return { version, commit };
59  } catch {
60    // no install record to read the commit from
61  }
62  return { version };
63}
64
65// The record after a prompt: created when there was none, then the time, the
66// handoff parent, and a new release entry when a reinstall changed the plugin
67// mid-session. cwd and branch count only on creation.
68export function touched(
69  old: SessionRecord | null,
70  f: { id: string; now: string; cwd: string; branch: string; parent: string; release: Release | null },
71): SessionRecord {
72  const rec: SessionRecord = old
73    ? { ...old, katharsis: [...old.katharsis] }
74    : { id: f.id, cwd: f.cwd, ...(f.branch ? { branch: f.branch } : {}), started: f.now, updated: f.now, katharsis: [] };
75  rec.updated = f.now;
76  if (f.parent) rec.parent = f.parent;
77  const last = rec.katharsis[rec.katharsis.length - 1];
78  if (f.release && (last?.version !== f.release.version || last?.commit !== f.release.commit)) rec.katharsis.push(f.release);
79  return rec;
80}
81
82// The record after a Stop, or null when nothing changed. A path is marked
83// seen once the file exists and stays seen after cleanup deletes it, so a
84// session run with --no-session-persistence keeps a path never seen.
85export function withTranscript(rec: SessionRecord, path: string, exists: boolean): SessionRecord | null {
86  const seen = Boolean(rec.transcriptSeen) || exists;
87  if (rec.transcript === path && Boolean(rec.transcriptSeen) === seen) return null;
88  return { ...rec, transcript: path, ...(seen ? { transcriptSeen: true } : {}) };
89}
90
hooks/suggest.ts 88 lines
1// suggest.ts: the autonomy level a person's own answers support. Like
2// ledger.ts, it holds no JSX and no engine calls, and its imports spell their
3// extensions.
4//
5// At guided, the drawer suggests standard once the person has answered at
6// least SUGGEST_MIN questions that carried a recommendation and took it on at
7// least SUGGEST_RATE of them. A count gates it rather than time since
8// install: a month of light use holds too few answers to judge from, and a
9// week of heavy use holds plenty. The rate was set against the one history on
10// hand, 94 of 127 answers taken (74%), and 80% would never have suggested
11// standard to the person the level was built for.
12
13import { itemsOf, threadTexts, type Io } from './ledger.ts';
14
15export const SUGGEST_MIN = 50;
16export const SUGGEST_RATE = 0.7;
17
18export type Agreement = { answered: number; took: number };
19
20// The option letter a recommendation line opens with: "a - why", "**b**. why".
21// The letter must stand alone before a separator, so "A cleaner fix - b" and
22// "I'd take b" name no option.
23export function recLetter(rec: string): string {
24  return rec.trim().match(/^\**([a-z])\**(?=\s*(?:[-–—.):,]|$))/i)?.[1]?.toLowerCase() ?? '';
25}
26
27// The session and every ancestor its chain file names, newest first. Unlike
28// thread() it has no cap of 20, so every session of a long chain sees the
29// chain's questions and keys them alike.
30async function chainOf(io: Io, data: string, sid: string): Promise<string[]> {
31  const ids = [sid];
32  for (;;) {
33    const next = (await io.read(`${data}/ledger/chains/${ids[ids.length - 1]}`))?.trim() ?? '';
34    if (!/^[\w-]+$/.test(next) || ids.includes(next)) return ids;
35    ids.push(next);
36  }
37}
38
39// Every answered question that carried a recommendation, across every
40// session's answers file, and how many took it. A question is its code in
41// one handoff chain's numbering space, keyed by the chain's oldest session,
42// and its latest answer by stamp is the one that counts, whichever session of
43// the chain gave it, and on equal stamps the later session's answer wins.
44// Two sessions punted from one parent share that numbering space, so their
45// two Q8s count as one question: an undercount, never an overcount. A
46// dismissed question (x) counts for neither side; the person's own
47// answer, a prose answer, or another letter counts as answered and not taken.
48export async function agreement(io: Io, data: string): Promise<Agreement> {
49  const latest = new Map<string, { ts: string; depth: number; letter: string; own: boolean; rec: string }>();
50  for (const f of await io.list(`${data}/answers`)) {
51    if (f.kind !== 'file' || !f.name.endsWith('.jsonl')) continue;
52    const text = await io.read(`${data}/answers/${f.name}`);
53    if (text === null) continue;
54    const rows: { ts: string; code: string; letter: string; own: boolean }[] = [];
55    for (const line of text.split('\n')) {
56      try {
57        const r = JSON.parse(line) as Record<string, unknown>;
58        if (typeof r.code === 'string') rows.push({ ts: String(r.ts ?? ''), code: r.code.toUpperCase(), letter: String(r.letter ?? '').toLowerCase(), own: r.how === 'own' });
59      } catch {
60        continue; // a blank or partial line
61      }
62    }
63    if (rows.length === 0) continue;
64    const ids = await chainOf(io, data, f.name.slice(0, -'.jsonl'.length));
65    const root = ids[ids.length - 1];
66    const depth = ids.length - 1;
67    const items = new Map(itemsOf(await threadTexts(io, `${data}/ledger`, ids, true)).map((i) => [i.code.toUpperCase(), i]));
68    for (const r of rows) {
69      const q = items.get(r.code);
70      // A recommendation must name one of the question's options, so "I'd
71      // take b" reads as no recommendation rather than as option i.
72      const rec = q ? recLetter(q.rec) : '';
73      const offered = !q || q.options.length === 0 || q.options.some((o) => o.key.toLowerCase() === rec);
74      const key = `${root}:${r.code}`;
75      const old = latest.get(key);
76      const later = !old || r.ts > old.ts || (r.ts === old.ts && depth >= old.depth);
77      if (q?.prefix === 'Q' && later) latest.set(key, { ts: r.ts, depth, letter: r.letter, own: r.own, rec: offered ? rec : '' });
78    }
79  }
80  const counted = [...latest.values()].filter((a) => a.rec && a.letter !== 'x');
81  return { answered: counted.length, took: counted.filter((a) => a.letter === a.rec && !a.own).length };
82}
83
84// Whether the answers support moving from guided to standard.
85export function suggestsStandard(a: Agreement): boolean {
86  return a.answered >= SUGGEST_MIN && a.took / a.answered >= SUGGEST_RATE;
87}
88