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…

A Claude Code output style that classifies each message you send and shapes the reply to fit it.
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.

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.
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.kref reads them back, so F3 still resolves after a context compaction or in the next session./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:
| Style | What it is |
|---|---|
katharsis:Katharsis | The style alone. Claude Code's built-in software-engineering instructions are dropped, which is the default for any custom output style. |
katharsis:Katharsis coding | The 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.
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.
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.
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.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.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.
| Type | The message looks like | Ceiling |
|---|---|---|
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-review | A script-sent review prompt naming a diff and a method | 300 |
harness-probe | "answer in one line", "reply with only the token, or NONE" | the named form |
default | Three or more types, a greeting, a pasted fragment | 250 |
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.
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 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.

! 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/
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 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.

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.

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.
| Level | What 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. |
standard | Further 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. |
autonomous | Everything 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
hooks/register.ts 300 lines1// 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};
300hooks/answers.ts 266 lines1// 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}
266hooks/drawer.tsx 1126 lines1// 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}
1126hooks/ledger.ts 327 lines1// 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}
327hooks/session.ts 90 lines1// 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}
90hooks/suggest.ts 88 lines1// 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