Records a project's decisions as a tree and shows it in a docked pane.

English | 简体中文
A general-purpose Claude Code mod that records the decisions made in a project as a tree and shows it in a pane docked on the right of the terminal.

Drawn by the pane's own layout code from a made-up sample project. The fonts and colors are those of your terminal; the picture uses a typical dark theme.
l) or a click on EN 中 switches the pane. In Chinese, professional names and everyday work words (bug, doc, commit, …) stay English. See Language.c. See Colors..claude/decisions/. Every session started in that project reads the same record and keeps adding to it; sync it with git and the tree follows you across machines.config.json.Quick start
/plugin install decision-tree --marketplace xwzhang98/decision-tree
Then, in any project, run /decide init: Claude looks through the project, records where it starts, and the pane fills in (open it with /tree). For an empty tree instead, tell Claude "start recording decisions from now" or run /decide start. Details are in Installation and Getting started.
claude plugin validate, tsc -p and claude plugin test all pass on that version). The mod API changes with every version: after changing versions, run validate and test first, see Development and tests./tree); on a narrower terminal it waits and appears when you widen the window. A pane you open yourself with /tree or /decide docks on the right from 110 columns; on a narrower terminal it is drawn above the prompt (see Behavior to know about).git rev-parse and git check-ignore (to find the project root and to check .gitignore), and the status command you have approved./decide translate off stops it; see Language./plugin install decision-tree --marketplace xwzhang98/decision-tree
Claude Code asks Add marketplace?: press y, choose Install for you (user scope) and press Enter. When Installed decision-tree. Plugin is now active. appears it is installed: it takes effect in the current session at once, and every session you start on this machine from now on loads it.
The same from the command line:
claude plugin marketplace add xwzhang98/decision-tree
claude plugin install decision-tree@decision-tree --scope user
To publish your own copy, push this folder as the root of a repository (.claude-plugin/marketplace.json is already written) and use /plugin install decision-tree --marketplace <owner>/<repo>.
Sessions on a remote server may be carrying unattended, long-running job queues. Do not restart them to install or debug: open another Claude Code session to install and try the mod. The sessions that are already running are not affected (they do not load the mod until you restart them).
~/mods/decision-tree: rsync -a --exclude '.claude-plugin/types' decision-tree/ server:~/mods/decision-tree/
(If the server can reach GitHub you can skip the copy and use the GitHub install above in that new session.)
/plugin install decision-tree --marketplace ~/mods/decision-tree
Press y at Add marketplace?, choose Install for you (user scope) and press Enter. When Installed decision-tree. Plugin is now active. appears it is installed: it takes effect in the current session at once, and every session you start on this machine from now on loads it.
~/mods/decision-tree directly and run /reload-plugins in the session.Outside the interactive UI you can use the command line:
claude plugin marketplace add ~/mods/decision-tree
claude plugin install decision-tree@decision-tree --scope user
claude --plugin-dir ~/mods/decision-tree
It applies to that one session only, and reloads by itself when you edit the files.
A project is not recorded by default. Without .claude/decisions/config.json the mod writes no file, the system prompt gets one extra line ("If the user asks to start recording this project's decisions …, call decision_log_start."), and the model sees only one tool, decision_log_start.
There are three ways to turn recording on:
/decide init [title]: it turns recording on and has Claude look through the project and record where it starts, see Setting up from the project itself;decision_log_start;/decide start [title] yourself.The last two begin with an empty tree.
Once on, the mod: writes config.json and a start event; registers the other six tools (decision_record, decision_update, decision_note, decision_tree, decision_new_tree, decision_use_tree); replaces the system-prompt section with the full version; and the session that turned it on opens the pane (when you turn it on with /decide start the pane also gets the keyboard, with /decide init it does not, because Claude starts working at once; it only draws if there is room, see Prerequisites). Other sessions already running in this project notice config.json within 5 seconds and switch to on as well; if their terminal uses the fullscreen layout and display.autoOpen is true, each of them opens the pane once.
Pause and resume with /decide pause and /decide resume. While paused, the model's write tools answer "Not recorded: decision recording is paused", and the system-prompt section shrinks to one line saying recording is paused. Your own commands and manual edits in the pane still work.
If the project's .gitignore ignores .claude/, turning recording on prints "Note: .gitignore ignores .claude/, so these files will not reach git." The mod never edits .gitignore. Git cannot re-include a path inside an ignored directory, so to get decisions/ into git change .claude/ to:
.claude/*
!.claude/decisions/
/decide init [title] is to this mod what /init is to Claude Code: instead of starting with an empty tree, Claude looks through the project and records where it stands.
Started recording decisions for notes-app (data in .claude/decisions/)
Asked Claude to look through the project and record where it starts; the
pane fills as it goes.
/decide start, except that the pane does not take the keyboard).CLAUDE.md / AGENTS.md, decision records (docs/adr, DECISIONS.md), the changelog, the last 30 commits and the tags, the main config and manifest files and the layout of the code. A quick look, about a dozen reads.decision_record: the root of the mainline (a milestone, adopted) saying what the project is and where it stands, with the files it used as sources.adopted for what the files say is decided or in use, rejected for alternatives they say were dropped, planned or proposed for what they list as next or still open. Each has its sources, and a decidedAt date when a commit or a document gives one. Nothing is invented: what the files do not show is left out.What it leaves behind looks like this (a made-up project, as Claude might record it):
notes-app EN 中
6 decisions mainline 1 step ● recording updated 15:28
▌ ◉ Notes app: v1 in production 03-14
┠─ ✓ Store notes as JSON files on disk
┠─ ✓ Sync through a REST endpoint, polling every 60 s
┠─ ✗ Real-time sync over websockets
┠─ ○ Offline-first rewrite for the spring release
┖─ ◌ Desktop client
╭─ Details ────────────────────────────────────────────────────────── ✓ adopted model 03-14 ─╮
│ │
│ Notes app: v1 in production │
│ │
│ A notes app for phones and the web, live since March. Notes are JSON files on disk and sync │
│ through a REST endpoint. │
│ │
│ Sources README.md │
│ CHANGELOG.md │
│ │
╰───────────────────────────────────────────────────────────────────────────────────────────────╯
k: ↑ j: ↓ ⏎: expand e: edit p: pending t: trees c: colors h: hide rejected a: all l: 中
It changes no file in your project (the record in .claude/decisions/ apart), runs none of the project's code, starts no jobs and skips secrets such as .env files. It writes only through the ordinary decision_record tool, so everything it records can be edited in the pane, corrected with /decide status, or taken back with /decide retract, like any other node. It is a request to the model, not a program: what it records depends on what the project's files say and how the model reads them, so look the result over. Reading the project goes through the usual permission prompts of your Claude Code settings.
/decide init sets up an empty tree only. In a tree that already has decisions it says so and does nothing (/decide new <name> --empty opens an empty tree to run it in); while recording is paused it asks you to /decide resume first. The task starts a turn of its own once the session is idle, so if Claude is busy it waits until the current turn ends. The records are written in Chinese when display.language is zh, or when it is auto and you have typed Chinese; in English when it is en; otherwise in the language you write in (English if Claude cannot tell). Whenever they are not English, Claude also hands over the English version of each text, so the pane can show English at once, see Language. The wording of the task is in lib/init.ts.
| Command | What it does | |||
|---|---|---|---|---|
/tree | Opens the pane (or switches to it) and gives it the keyboard. If the screen has no room for the pane, returns the tree as text | |||
/decide init [title] | Turn recording on and have Claude look through the project and record where it starts, see Setting up from the project itself | |||
/decide start [title] | Turn recording on by hand, with an empty tree | |||
/decide pause, /decide resume | Pause, resume | |||
/decide status <id> <status> | Change a status, e.g. /decide status replay-200 adopted. A status can be written as its id, its English name or its Chinese name (采用, 否决, 进行中, …) | |||
/decide note <id> <text> | Add a note | |||
/decide add <title> --parent <id> [--status planned] | Add a node by hand. Also `--kind decision\ | protocol\ | design\ | milestone, --id <slug>, --trunk` |
/decide retract <id> <reason> | Take back a record made by mistake (not the same as a decision being withdrawn) | |||
/decide import <file> | Import a batch of nodes, see Importing seed data | |||
/decide new <name> [--at <version>] [--from <tree>] [--title <text>] [--empty] [--stay] | Open a new tree: it starts as a copy of the current version (or of the version --at names) and the project switches to it, see Several trees | |||
/decide use <name> | Switch to another tree (main is the first one) | |||
/decide tree | List the project's trees, where each was opened from, and how big each is | |||
/decide log [N] | The last N events of the current tree (default 12, at most 60), numbered: #N is a version you can use with --at | |||
/decide (no arguments) | Open the pane and start editing the selected node | |||
| `/decide lang en\ | zh\ | auto` | Fix the pane's language for everybody in the project, or go back to automatic (written to config.json; it also drops the choice you made yourself with the l key), see Language | |
| `/decide translate on\ | off` | Whether texts that lack a version in the other language are translated in the background by your session's model (written to config.json); on also makes it try again after it gave up | ||
| `/decide colors [name\ | reset]` | The color scheme: without a name it says which one is in use, reset goes back to Terminal. Kept for you, in every project, see Colors | ||
| `/decide autoopen on\ | off` | Whether the pane opens by itself once recording is on |
Wherever <id> is expected you can also give a piece of a title that matches exactly one node; if several match, the command lists the candidate ids. Events written by hand have actor "user". A command returns a one-line receipt. The three tree commands also have a long form, /decide tree new|use|log (list is the same as /decide tree); new can also be written fork, 新建 or 新开, and use can also be written switch or 切换. /decide colors can also be written /decide 配色.
The pane opens 72 columns wide and 24 rows tall (the layout works from 16 columns up and adapts to whatever room it is given). This is what it draws, as text, at that size (English, the made-up sample project):
notes-app main ● crdt-engine cloud-first EN 中
17 decisions mainline 6 steps ● recording updated 15:28
▲ 5 more above
● Sync: clients send operations, the server decides their … 09-21
┠─ ✗ CRDT text engine: too heavy for older phones
┠─ ⊘ Compress operations before sending
▌ ● Conflicts: merge by paragraph, last edit wins for titles 10-01 •
┠─ = Merge window of 5 s instead of 10 s
┠─ ✓ Show a banner when a note was merged
▼ 6 more below
╭─ Details ────────────────────────────── ✓ adopted model 10-01 ─╮
│ Conflicts: merge by paragraph, last edit wins for titles │
│ Body text merges by paragraph; titles and tags take the last │
│ edit. Replaying 200 recorded edit sessions left 3 conflicts, all │
│ in titles. │
│ Rationale A paragraph is the smallest unit people think of as │
│ "the thing I changed", so merging by it surprises them │
│ … │
╰───────────────────────────────────────────────────────────────────╯
k: ↑ j: ↓ ⏎: expand e: edit p: pending t: trees l: 中
o: next tree c: colors h: hide rejected a: all
From top to bottom:
●. At the right, ◌ translating 14 while texts are being translated, and the language switch EN 中, whose current language is bold and underlined (see Language).● recording, ● paused) and when it last changed. When you are looking at a tree the project does not record into, it reads ○ read only recording in main instead. In a narrow pane the update time goes first, then the mainline.↳ main #5 (09-21) CRDT text engine instead of paragraph merge, where it began and what the tree is for.user, model, gate) and the date. When the node replaces or is replaced by another one, a line says so.l never goes.The pane is a normal surface of Claude Code: everything in it is clickable (rows, tabs, EN 中, the key hints, ▲ … more above), and ↑ ↓ work as well as k j.
| Mark | Meaning |
|---|---|
● | A step of the mainline |
◉ | The head: the newest step that was adopted, where the project is |
○ (dim) | A step still ahead: planned or proposed |
┃ and ╏ | The spine between steps: solid once the way there is done, dashed where it leads on to a step still ahead |
┠─ | A branch: something tried or decided at that step, in the order it was decided. ┖─ is the last branch of the last step, where the spine ends |
╰─ | A branch of a branch |
• | At the end of a row: changed in the last 24 hours |
+3 | Three rows folded away under this one |
▌ | The selected row (with a band across the pane) |
▲ 5 more above ▼ 6 more below | The rail scrolls; these are buttons that page |
The steps are the nodes marked trunk (the mainline) and the roots; everything else hangs off the step above it. The branches of a step are open by default, and so are the sub-branches that are still pending or changed in the last 48 hours; pressing ⏎ on the selected row opens or closes what is under it (the p and a filters open everything). In a tree with no trunk node the roots are the steps.
Status symbols and colors (the colors are theme colors; a color scheme may recolor them):
| status | symbol | ASCII | color |
|---|---|---|---|
adopted | ✓ | + | green |
rejected | ✗ | x | red |
no-diff | = | = | gray |
in-progress | ◐ | ~ | accent |
planned | ○ | o | accent |
proposed | ◌ | ? | accent, dim |
withdrawn | ⊘ | / | gray, dim |
superseded | ⊘ | / | gray, dim |
deferred | ‖ | " | gray, dim |
Decisions that are over or parked (rejected, withdrawn, superseded, deferred) are drawn dim, title and all. The names are the same in both languages of the pane, because they are what the commands take.
Words that matter are colored the way code is, always the same kind of word in the same color, and nothing else:
| Kind | Examples | Color |
|---|---|---|
| Topic | the part of a title before its first colon (Sync:), and file names in Sources | blue |
| Number | 99.5%, 5 s, 5,000, #5 | yellow |
| Term | SQLite, CRDT, proto_b (ALLCAPS, snake_case, camelCase) | cyan |
| Quote | "soak test" | green |
| Date | 09-21, 15:28 | gray |
In the details the model shows in Claude's color and the person in plain text.
◐ ○ ◌ ⊘ and the line and block characters (─ ┃ ╭ █ ░ …) have "ambiguous width" in Unicode, and some terminals draw them two columns wide. By default they are counted as one column (as Claude Code's own layout does). If your terminal draws them crooked, set display.asciiGlyphs: true in config.json, and the status symbols, the rail, the frames, the marks and the progress bars switch to ASCII (this is the same pane at 80 columns):
notes-app main * crdt-engine cloud-first EN 中
17 decisions mainline 6 steps * recording updated 15:28
^ 4 more above
|- x Keep JSON files and add a lock file
* Sync: clients send operations, the server decides their order 09-21
|- x CRDT text engine: too heavy for older phones
|- / Compress operations before sending
> * Conflicts: merge by hooks/register.tsx 165 lines1// decision-tree: records a project's decisions as a tree and shows it in a pane.
2//
3// This file only wires the engine's events to the Runtime (lib/runtime.ts); the
4// work is in lib/, which has the tests. Read README.md for what the mod does.
5//
6// The engine refuses a hooks module that hands `$` to a function of another file,
7// so `portOf` (declared here, where the engine can follow `$` into it) spells out
8// each engine call lib/ may make, and lib/ takes that object instead of `$`.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { Port } from '../lib/port'
14import { PANE_ID, Runtime } from '../lib/runtime'
15import { EMPTY_MODEL, EMPTY_NOW, EMPTY_PROJECT, EMPTY_TM, EMPTY_TREES, EMPTY_UI } from '../lib/state'
16import { DEFAULT_SCHEME } from '../lib/palettes'
17import { TOOL_PREFIX, shortName } from '../lib/tools'
18import { drawPane, isNodeKey } from '../lib/view'
19
20// The tools of this mod are told apart by their name prefix: the engine types a name list from the tools this mod had
21// registered the last time it loaded (which depends on whether that project was recording), a pattern is not held to it.
22// Which of our tools a call is for is checked at run time (`shortName`).
23const TOOL_PATTERN = new RegExp(`^${TOOL_PREFIX}`)
24
25// The values kept in `$.state` (the contract is types/index.d.ts). They are declared here
26// because the engine lists what a module reads and writes from the atoms it can see.
27const modelAtom = atom({ plugin: 'decision-tree', key: 'model' } as const, EMPTY_MODEL)
28const projectAtom = atom({ plugin: 'decision-tree', key: 'project' } as const, EMPTY_PROJECT)
29const uiAtom = atom({ plugin: 'decision-tree', key: 'ui' } as const, EMPTY_UI)
30const langAtom = atom({ plugin: 'decision-tree', key: 'lang' } as const, 'en' as const)
31const nowAtom = atom({ plugin: 'decision-tree', key: 'now' } as const, EMPTY_NOW)
32const tmAtom = atom({ plugin: 'decision-tree', key: 'tm' } as const, EMPTY_TM)
33const treesAtom = atom({ plugin: 'decision-tree', key: 'trees' } as const, EMPTY_TREES)
34const schemeAtom = atom({ plugin: 'decision-tree', key: 'scheme' } as const, DEFAULT_SCHEME)
35const translatingAtom = atom({ plugin: 'decision-tree', key: 'translating' } as const, 0)
36const ownerAtom = atom({ plugin: 'decision-tree', key: 'owner' } as const, '')
37
38function portOf($: EngineInterface): Port {
39 return {
40 session: { cwd: () => $.session.cwd(), id: () => $.session.id() },
41 clock: { now: () => $.clock.now(), every: (ms, fn) => $.clock.every(ms, fn), after: (ms, fn) => $.clock.after(ms, fn) },
42 store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) },
43 fs: {
44 read: path => $.fs.read(path),
45 write: (path, text) => $.fs.write(path, text),
46 list: path => $.fs.list(path),
47 exists: path => $.fs.exists(path),
48 },
49 process: { run: (argv, options) => $.process.run(argv, options) },
50 command: { register: spec => $.command.register(spec) },
51 prompt: { submit: input => $.prompt.submit(input) },
52 tool: { register: spec => $.tool.register(spec) },
53 model: { complete: (request, options) => $.model.complete(request, options) },
54 ui: {
55 open: args => $.ui.open(args),
56 panes: () => $.ui.panes(),
57 focus: args => $.ui.focus(args),
58 toast: (text, options) => $.ui.toast(text, options),
59 invalidate: () => $.ui.invalidate('ui.render'),
60 },
61 state: {
62 model: { read: () => read($, modelAtom), update: change => update($, modelAtom, change) },
63 project: { read: () => read($, projectAtom), update: change => update($, projectAtom, change) },
64 ui: { read: () => read($, uiAtom), update: change => update($, uiAtom, change) },
65 lang: { read: () => read($, langAtom), update: change => update($, langAtom, change) },
66 now: { read: () => read($, nowAtom), update: change => update($, nowAtom, change) },
67 tm: { read: () => read($, tmAtom), update: change => update($, tmAtom, change) },
68 trees: { read: () => read($, treesAtom), update: change => update($, treesAtom, change) },
69 scheme: { read: () => read($, schemeAtom), update: change => update($, schemeAtom, change) },
70 translating: { read: () => read($, translatingAtom), update: change => update($, translatingAtom, change) },
71 owner: { read: () => read($, ownerAtom), update: change => update($, ownerAtom, change) },
72 },
73 }
74}
75
76/** The arguments of a tool call: what the engine adds beside them is left out. */
77function argsOf(e: Record<string, unknown>): Record<string, unknown> {
78 const { tool: _tool, tool_use_id: _id, agentId: _agent, ...args } = e
79 return args
80}
81
82export const register: Register = on => {
83 const rt = new Runtime()
84
85 // Everything starts here, awaited before the first prompt, so the tools are listed by turn one.
86 on('session.start', async ($, e, next) => {
87 await $.command.register({ name: 'tree', description: 'Open the decision tree pane', immediate: true })
88 await $.command.register({
89 name: 'decide',
90 description: 'Record and edit decisions: init, start, pause, resume, status, note, add, retract, import, tree, lang, translate, colors',
91 argumentHint: '[init|start|pause|resume|status|note|add|retract|import|tree|lang|translate|colors] ...',
92 immediate: true,
93 })
94 try {
95 await rt.ensure(portOf($))
96 } catch (err) {
97 $.ui.toast(`decision-tree: could not start (${err instanceof Error ? err.message : String(err)})`)
98 }
99 return next(e)
100 })
101
102 // A /clear and a resume start the session over inside this process: the poll goes on (the session's
103 // `$.state` is empty then, and the Runtime puts it back); any other ending is the process going.
104 on('session.end', ($, e, next) => {
105 if (e.reason !== 'clear' && e.reason !== 'resume') rt.stop()
106 return next(e)
107 })
108
109 // ---- what the model calls
110 on('tool.call', { tool: TOOL_PATTERN }, async ($, e) => {
111 const name = shortName(e.tool)
112 if (name === null) return { deny: `decision-tree: unknown tool ${e.tool}` }
113 return rt.toolCall(portOf($), name, argsOf(e))
114 }).catch(() => ({ deny: 'decision-tree: this call failed inside the mod and nothing was recorded. Tell the user.' }))
115
116 // ---- what the person types
117 on('command.run', { command: 'tree' }, async $ => ({ text: await rt.command(portOf($), 'tree', '') })).catch(() => ({ text: 'decision-tree: this command failed inside the mod.' }))
118 on('command.run', { command: 'decide' }, async ($, e) => ({ text: await rt.command(portOf($), 'decide', e.args ?? '') })).catch(() => ({ text: 'decision-tree: this command failed inside the mod.' }))
119
120 // ---- the system prompt, once per turn
121 on('turn.start', async ($, e, next) => {
122 await rt.onTurnStart(portOf($))
123 return next(e)
124 })
125
126 on('prompt.compose', async ($, e, next) => {
127 const composed = await next(e)
128 const section = rt.section()
129 return section === null ? composed : { ...composed, sections: [...composed.sections, section] }
130 })
131
132 // the language the person writes in decides the language of the pane and the receipts (what a notification,
133 // a schedule or another plugin sends is not theirs: the runtime looks at where the prompt came from)
134 on('prompt.submit', async ($, e, next) => {
135 try {
136 await rt.onPrompt(portOf($), e.text, e.origin?.kind)
137 } catch {
138 // the language stays as it was
139 }
140 return next(e)
141 })
142
143 // ---- the pane
144 on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => drawPane(portOf($), e, $.ui.resolve(e), rt))
145
146 // the band is not ours; looking at it tells us whether the screen can dock a pane
147 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
148 rt.seeViewport(e.viewport)
149 return next(e)
150 })
151
152 // the selected node follows the keyboard's focus ring (Tab, arrows, a click); so does the tree highlighted in the overview
153 on('ui.focus', { requestId: PANE_ID }, async ($, e, next) => {
154 if (e.component === 'Pane' && e.plugin === $.plugin.name && e.element !== undefined) {
155 try {
156 if (isNodeKey(e.element)) await rt.select(portOf($), e.element)
157 else await rt.focusedControl(portOf($), e.element)
158 } catch {
159 // the ring still moves
160 }
161 }
162 return next(e)
163 })
164}
165lib/port.ts 65 lines1// What this mod asks of the engine, as a plain object.
2//
3// The engine refuses a hooks module that hands `$` to a function in another file
4// (it must be able to list every `$.noun.method()` the module calls). So
5// hooks/register.tsx builds one of these from `$`, each member spelled out where
6// it is written, and everything under lib/ takes the Port instead. A test builds
7// one from memory (tests/port.ts) and needs no engine at all.
8//
9// The members are the engine's own signatures, so what a call takes and
10// returns is exactly what `$` takes and returns.
11
12import type { EngineInterface } from 'claude-code'
13
14import type { DtLang, DtModel, DtNow, DtProject, DtTm, DtTreeInfo, DtUi } from '../types'
15
16type E = EngineInterface
17
18export type Port = {
19 session: Pick<E['session'], 'cwd' | 'id'>
20 clock: Pick<E['clock'], 'now' | 'every' | 'after'>
21 store: Pick<E['store'], 'get' | 'set'>
22 fs: Pick<E['fs'], 'write' | 'list' | 'exists'> & {
23 /** The file's text (`$.fs.read(path)`). */
24 read: (path: string) => Promise<string>
25 }
26 process: Pick<E['process'], 'run'>
27 command: Pick<E['command'], 'register'>
28 /** Hands the model a prompt of its own, which starts a turn once the session is idle (`/decide init`). */
29 prompt: Pick<E['prompt'], 'submit'>
30 tool: Pick<E['tool'], 'register'>
31 /** One completion through the session's own client: the translator asks for the other language's version of a text (`display.translate` switches it off). */
32 model: Pick<E['model'], 'complete'>
33 ui: Pick<E['ui'], 'open' | 'panes' | 'focus' | 'toast'> & {
34 /** Draws the pane again (its ages move with the clock). */
35 invalidate: () => void
36 }
37 /**
38 * The values in `$.state`, one cell each. The engine lists the values a module reads and
39 * writes from the atoms it can see, so each cell is tied to its atom in register.tsx.
40 */
41 state: {
42 model: Cell<DtModel>
43 project: Cell<DtProject>
44 ui: Cell<DtUi>
45 lang: Cell<DtLang>
46 now: Cell<DtNow>
47 /** The translation memory: what the texts of the tree read like in the other language. */
48 tm: Cell<DtTm>
49 /** Every tree of the project, for the tabs and the overview. */
50 trees: Cell<DtTreeInfo[]>
51 /** The colour scheme in use (an id of lib/palettes.ts). */
52 scheme: Cell<string>
53 /** How many texts wait for their rendition. */
54 translating: Cell<number>
55 /** Token of the Runtime that owns the poll (a reload replaces it). */
56 owner: Cell<string>
57 }
58}
59
60/** `read($, atom)` and `update($, atom, change)` of one atom. */
61export type Cell<T> = {
62 read: () => Promise<T>
63 update: (change: (value: T) => T) => Promise<T>
64}
65lib/runtime.ts 1979 lines1// What the mod does while a session runs: finds the project, keeps the record
2// and the pane's state in step with the disk, serves the model's tools and the
3// person's commands, and runs the status command.
4//
5// One Runtime per module load. Everything that must survive a hot reload lives in
6// `$.state` and `$.store`; this object is rebuilt, and `start` reads it all again.
7// Writes to the record are serialized by `exclusive`, so a tool call, a command
8// and the 5-second poll never interleave.
9
10import type { ModelCompleteResult } from 'claude-code'
11
12import type { DtActor, DtConfig, DtFilter, DtLang, DtMode, DtModel, DtNewTree, DtNow, DtTm, DtTreeInfo, DtUi } from '../types'
13import { NEW_KEYS, schemeKey, schemeOfKey, treeKey, treeOfKey } from './bar'
14import { parseConfig, setDisplay, setRecording } from './config'
15import { parseDecide } from './commands'
16import type { Parsed } from './commands'
17import { MAIN, isStatus, shortSession } from './events'
18import { MAX_TREES, orderTrees, treeInfoOf } from './forest'
19import { detectLanguage, t } from './i18n'
20import { initPrompt } from './init'
21import type { InitWrite } from './init'
22import type { Dollar } from './io'
23import { engineIo, isIgnoredByGit, projectIo } from './io'
24import { Journal, firstLine } from './journal'
25import { EMPTY_TM, wantedRenditions } from './lang'
26import type { Want } from './lang'
27import type { Ctx, Done } from './ops'
28import type { NewTreeInput } from './ops'
29import { addNode, importNodes, newTree, noteNode, recordNode, retractNode, startRecord, treeList, treeLog, updateNode, useTree } from './ops'
30import { DEFAULT_SCHEME, SCHEMES, findScheme, schemeOf } from './palettes'
31import { forkText } from './versions'
32import { SECTION_ID, sectionText } from './prompt'
33import { baseName, findRoot, resolveFrom } from './project'
34import { EMPTY_NOW } from './state'
35import { approvalHash, failureText, parseStatusOutput, sameArgv, storeKey } from './status'
36import { TmStore } from './tm'
37import { SPECS, WRITE_TOOLS, treeReport } from './tools'
38import type { ToolName } from './tools'
39import { MAX_TEXT_TRIES, failureOf, nextBatch, readReply, requestFor, retryDelay } from './translator'
40import { buildIndex, effectiveSelection, resolveRef, revealPath, rowIndex, visibleRows } from './tree'
41import { oneLine } from './width'
42
43export const PANE_ID = 'decision-tree'
44/** How often the record and config.json are looked at again. */
45export const POLL_MS = 5000
46/** The longest one operation on the record may take; a disk that does not answer must not hold up everything behind it. */
47export const OP_TIMEOUT_MS = 30_000
48/** Looking for new events is given less: when the disk does not answer, the last good model is shown instead. */
49export const SYNC_TIMEOUT_MS = 10_000
50/** Opening a tree copies every event file of the version it starts from: more than one read or write. */
51export const TREE_TIMEOUT_MS = 120_000
52/**
53 * How long a look at the other trees of the project may take (they are read in turns, so nothing else waits
54 * for more than one tree): at the start of a session, when the overview opens, and at each poll.
55 */
56export const TREES_START_MS = 4000
57export const TREES_OPEN_MS = 8000
58export const TREES_POLL_MS = 12_000
59const MAX_LISTED = 500
60/** How many wanted texts the translator looks at in one round, and how many rounds one run may take before the next poll carries on. */
61const WANT_WINDOW = 400
62const MAX_TRANSLATE_ROUNDS = 40
63/** Failures in a row after which the person is told, and the pane stops saying it is translating. */
64const TELL_AFTER = 3
65/** Where the colour scheme is kept: one value for every project and every session. */
66const SCHEME_STORE_KEY = 'scheme'
67/**
68 * How long after `/decide init` is done each attempt to hand the model its job is made, the first and then each retry
69 * (the engine refuses the prompt while the command that asks for it is still running).
70 */
71export const INIT_SEND_AFTER_MS: readonly number[] = [250, 1000, 3000]
72/** The origins of a prompt (`PromptOrigin['kind']`) that are a machine's or another party's words, not the person's own. */
73export const NOT_THE_PERSON: ReadonlySet<string> = new Set([
74 'plugin',
75 'task-notification',
76 'scheduled-trigger',
77 'peer',
78 'peer-send-message',
79 'projects-relay',
80 'coordinator',
81 'observer',
82 'observer-activity',
83 'auto-continuation',
84])
85/** What the model is told when it calls a tool of a project that is not being recorded. */
86const NOT_RECORDING = 'Decisions are not being recorded in this project. If the user wants that, call decision_log_start.'
87
88type Loose = Record<string, unknown>
89export type ToolAnswer = { result: string } | { deny: string }
90
91/** What the tree commands and tools ask for, however they were asked. */
92type TreeOp = { kind: 'list' } | { kind: 'log'; limit: number | null } | { kind: 'new'; input: NewTreeInput } | { kind: 'use'; name: string }
93
94const answer = (r: Done): ToolAnswer => (r.ok ? { result: r.text } : { deny: r.error })
95
96function add(list: readonly string[], id: string): string[] {
97 return list.includes(id) ? [...list] : [...list, id].slice(-MAX_LISTED)
98}
99
100function drop(list: readonly string[], id: string): string[] {
101 return list.filter(x => x !== id)
102}
103
104export class Runtime {
105 ready = false
106 private starting: Promise<void> | null = null
107 private chain: Promise<unknown> = Promise.resolve()
108 /** The tree the project records into. */
109 private journal: Journal | null = null
110 /** Every tree of the project that is known, the one recorded into among them (a journal keeps what it has read of its tree). */
111 private journals = new Map<string, Journal>()
112 private timer: { cancel: () => void } | null = null
113
114 root = ''
115 private session = ''
116 mode: DtMode = 'off'
117 private config: DtConfig | null = null
118 private configText: string | null = null
119 private configError: string | null = null
120 /** The language the last prompt was written in (used while `display.language` is auto). */
121 private auto: DtLang = 'en'
122 /**
123 * The language the person chose with the switch in the pane: it beats config.json and the language of the
124 * prompts, for this project on this machine, until `/decide lang` sets one for everybody.
125 */
126 private pinned: DtLang | null = null
127 lang: DtLang = 'en'
128
129 private registered = new Set<ToolName>()
130 /** What `$.state.model` holds: the tree the pane shows, and its model as it was read. */
131 private published: { tree: string; model: DtModel } | null = null
132 /** What `$.state.trees` holds, and the facts about each tree worked out for the model they were worked out from. */
133 private publishedTrees: DtTreeInfo[] | null = null
134 private infos = new Map<string, { model: DtModel; info: DtTreeInfo }>()
135 /** True while the other trees are being looked at (one round at a time). */
136 private treesBusy = false
137 private promptText = ''
138 nowMs = 0
139
140 /** The project's translation memory (`.claude/decisions/i18n/`), and the memory `$.state.tm` holds. */
141 private tmStore: TmStore | null = null
142 private publishedTm: DtTm | null = null
143 /** The translator: one request at a time, a rest after a failure, and what it has already been told about. */
144 private translatorBusy = false
145 /** The colour scheme in use (an id of palettes.ts). */
146 private scheme = DEFAULT_SCHEME
147 private translateFailures = 0
148 private translateRetryAt = 0
149 private translateStuck = false
150 private translateTold = false
151 private publishedTranslating: number | null = null
152 /** How often each text was asked for (`<language>\0<text>`): one that keeps coming back unusable is left alone. */
153 private tries = new Map<string, number>()
154 private abort = new AbortController()
155
156 private statusBusy = false
157 private lastStatusAt = 0
158
159 /** The engine as the last hook that called in gave it: the watchdog of `exclusive` needs a timer. */
160 private port: Dollar | null = null
161 private ticking = false
162 /** Why the record could not be read, while it cannot (null when it can). */
163 private readProblem: string | null = null
164 /** The tree config.json names while this project has no such tree (yet); null when the journal is on the tree it names. */
165 private treeProblem: string | null = null
166 /** True while this object itself is moving the project to another tree: the person was just told, no toast. */
167 private quietSwitch = false
168 /** Reads of config.json in a row that found it gone although it had been there. */
169 private absentReads = 0
170
171 private isFullscreen = false
172 private autoOpened = false
173 private lastDraw = 0
174 private lastTick = 0
175 /** Names this object; a reload that built a newer one writes its own, and this one stops. */
176 private readonly token = typeof crypto !== 'undefined' ? crypto.randomUUID() : String(Math.random()).slice(2)
177 private reclaiming: Promise<void> | null = null
178 private repairAt = false
179
180 // ---------------------------------------------------------------- lifecycle
181
182 /** Starts once; later calls wait for it. */
183 async ensure($: Dollar): Promise<void> {
184 this.port = $
185 if (this.ready) {
186 await this.holds($)
187 return
188 }
189 if (this.starting === null) {
190 this.starting = this.start($).finally(() => {
191 this.starting = null
192 })
193 }
194 await this.starting
195 }
196
197 private async start($: Dollar): Promise<void> {
198 this.nowMs = await this.clock($)
199 await $.state.owner.update(() => this.token)
200 const cwd = await $.session.cwd()
201 this.root = await findRoot(projectIo($), cwd)
202 this.session = shortSession(await $.session.id().catch(() => ''))
203 this.journal = new Journal(engineIo($), this.root, this.session)
204 this.journals = new Map([[MAIN, this.journal]])
205 this.tmStore = new TmStore(engineIo($), this.root, this.session)
206
207 const stored = await $.store.get(`lang:${this.root}`).catch(() => undefined)
208 if (stored === 'zh' || stored === 'en') this.auto = stored
209 else this.auto = await $.state.lang.read()
210 const pin = await $.store.get(`pin:${this.root}`).catch(() => undefined)
211 this.pinned = pin === 'zh' || pin === 'en' ? pin : null
212 this.scheme = await this.readScheme($)
213
214 await this.readConfig()
215 this.lang = this.effectiveLang()
216 this.quietSwitch = true
217 try {
218 await this.selectTree($)
219 } finally {
220 this.quietSwitch = false
221 }
222 if (this.mode !== 'off') {
223 await this.syncJournal($)
224 await this.syncTrees($, TREES_START_MS, true)
225 await this.syncTm($)
226 }
227
228 await this.registerTools($)
229 await this.syncNow($)
230 await this.publishAll($)
231 this.refreshPrompt()
232 this.timer = $.clock.every(POLL_MS, () => {
233 void this.tick($)
234 })
235 this.ready = true
236 }
237
238 /** Stops the poll (a test, or a reload that rebuilt the object), and a translation that is under way. */
239 stop(): void {
240 this.timer?.cancel()
241 this.timer = null
242 this.abort.abort()
243 }
244
245 /**
246 * Whether this object still has the session's state, and puts the state back when a /clear or a
247 * resume wiped it. A reload builds a newer object that writes its own token; the older one lets go.
248 */
249 async holds($: Dollar): Promise<boolean> {
250 this.port = $
251 const owner = await $.state.owner.read()
252 if (owner !== '' && owner !== this.token) return false
253 if (owner === this.token && (await $.state.project.read()).root !== '') return true
254 await this.reclaim($)
255 return true
256 }
257
258 /**
259 * A /clear (or a resume) starts the session over inside this process: the values in `$.state` are
260 * empty again while this object, its poll and the tools it registered live on. Everything it knows is
261 * written again, so the pane does not go on saying "not recording" until some event happens to change it.
262 */
263 private reclaim($: Dollar): Promise<void> {
264 if (this.reclaiming === null) {
265 this.reclaiming = this.exclusive(async () => {
266 await $.state.owner.update(() => this.token)
267 this.published = null
268 this.publishedTrees = null
269 this.publishedTm = null
270 this.publishedTranslating = null
271 this.lastStatusAt = 0
272 const id = await $.session.id().catch(() => '')
273 if (id !== '') {
274 for (const j of this.journals.values()) j.session = shortSession(id)
275 if (this.tmStore !== null) this.tmStore.session = shortSession(id)
276 }
277 await this.readConfig()
278 await this.applyMode($)
279 }).finally(() => {
280 this.reclaiming = null
281 })
282 }
283 return this.reclaiming
284 }
285
286 /**
287 * A drawing found the state wiped: repair it right after the drawing is over (a drawing may not
288 * write), not at the next poll, so the pane does not stay empty for a few seconds after a /clear.
289 */
290 repairSoon($: Dollar): void {
291 if (!this.ready || this.repairAt) return
292 this.repairAt = true
293 try {
294 $.clock.after(0, () => {
295 void this.holds($)
296 .catch(() => undefined)
297 .finally(() => {
298 this.repairAt = false
299 })
300 })
301 } catch {
302 this.repairAt = false // the next poll repairs it
303 }
304 }
305
306 /**
307 * One item at a time through everything that writes or re-reads the record. An item that takes longer
308 * than OP_TIMEOUT_MS is given up on (its caller is told, the line moves on), so one hung disk call
309 * cannot hold every later tool call, command and poll for good.
310 */
311 exclusive<T>(fn: () => Promise<T>, ms: number = OP_TIMEOUT_MS): Promise<T> {
312 const guarded = () => this.bounded(fn(), ms)
313 const run = this.chain.then(guarded, guarded)
314 this.chain = run.then(
315 () => undefined,
316 () => undefined,
317 )
318 return run
319 }
320
321 private bounded<T>(work: Promise<T>, ms = OP_TIMEOUT_MS): Promise<T> {
322 const port = this.port
323 if (port === null) return work
324 return new Promise<T>((resolve, reject) => {
325 let settled = false
326 let timer: { cancel: () => void } | null = null
327 const finish = (then: () => void) => {
328 if (settled) return
329 settled = true
330 timer?.cancel()
331 then()
332 }
333 try {
334 timer = port.clock.after(ms, () => finish(() => reject(new Error(`no answer after ${ms / 1000} s (the disk or a command is not responding)`))))
335 } catch {
336 // no timer to be had: wait for the work as long as it takes
337 }
338 work.then(
339 v => finish(() => resolve(v)),
340 e => finish(() => reject(e)),
341 )
342 })
343 }
344
345 /**
346 * Reads the events not seen yet. A disk that does not answer leaves the last good model in place (the
347 * person is told once); true when the model changed.
348 */
349 private async syncJournal($: Dollar): Promise<boolean> {
350 try {
351 const changed = await this.bounded(this.journal!.sync(), SYNC_TIMEOUT_MS)
352 if (this.readProblem !== null) {
353 this.readProblem = null
354 $.ui.toast(t(this.lang, 'r.readBack'))
355 }
356 return changed
357 } catch (err) {
358 if (this.readProblem === null) $.ui.toast(t(this.lang, 'r.readFailed', { error: firstLine(err) }))
359 this.readProblem = firstLine(err)
360 return false
361 }
362 }
363
364 private async clock($: Dollar): Promise<number> {
365 try {
366 const ms = await $.clock.now()
367 if (Number.isFinite(ms)) {
368 this.nowMs = ms
369 return ms
370 }
371 } catch {
372 // fall through to the process clock
373 }
374 this.nowMs = Date.now()
375 return this.nowMs
376 }
377
378 /**
379 * `now()` for one operation: every call a millisecond later than the last one this object
380 * made (also across operations), so events keep their order in the file names.
381 */
382 private async ctx($: Dollar, actor: DtActor): Promise<Ctx> {
383 const now = await this.clock($)
384 return {
385 journal: this.journal!,
386 now: () => {
387 this.lastTick = Math.max(now, this.lastTick + 1)
388 return this.lastTick
389 },
390 lang: this.lang,
391 actor,
392 // (inside `exclusive` already: the memory is written and published here, not queued behind the call that is waiting for it)
393 english: async pairs => {
394 if (this.tmStore === null) return
395 await this.tmStore.append('en', pairs, { actor, via: 'tool' }, now)
396 await this.publishTm($)
397 },
398 }
399 }
400
401 // ---------------------------------------------------------------- project, config, mode
402
403 /**
404 * Reads config.json; true when what it says changed since the last read. `poll` is for the poll alone:
405 * a file that is not there counts as gone only when two polls in a row, a few seconds apart, find it
406 * missing; a tool call or a command that looks in between (a git checkout may take a moment) does not
407 * count as one of the two.
408 */
409 private async readConfig(poll = false): Promise<boolean> {
410 const read = await this.journal!.readConfig()
411 // a read that failed says nothing about the file: what was known stays
412 if (read.kind === 'error') return false
413 let text: string | null = null
414 if (read.kind === 'text') {
415 this.absentReads = 0
416 text = read.text
417 } else if (this.configText !== null && (!poll || ++this.absentReads < 2)) {
418 // it was there and is not now: it may be mid-replacement (a checkout, an editor saving), so it has to be gone twice in a row
419 return false
420 }
421 if (text === this.configText) return false
422 this.configText = text
423 if (text === null) {
424 this.config = null
425 this.configError = null
426 this.mode = 'off'
427 } else {
428 const parsed = parseConfig(text)
429 if ('error' in parsed) {
430 this.config = null
431 this.configError = parsed.error
432 this.mode = 'off'
433 } else {
434 this.config = parsed.config
435 this.configError = null
436 this.mode = parsed.config.recording ? 'recording' : 'paused'
437 }
438 }
439 return true
440 }
441
442 /**
443 * Reads config.json and, when it changed, puts everything that follows from it in step (tools, state,
444 * the "now" block, the prompt). Every place that reads the config to decide something goes through
445 * here, so what the pane shows and what the mod does never come from two different configs.
446 * Call inside `exclusive`.
447 */
448 private async refreshConfig($: Dollar, poll = false): Promise<boolean> {
449 const changed = await this.readConfig(poll)
450 if (changed) await this.applyMode($)
451 return changed
452 }
453
454 /** What the person chose with the switch, else what config.json fixes, else the language of the prompts. */
455 private effectiveLang(): DtLang {
456 if (this.pinned !== null) return this.pinned
457 const fixed = this.config?.display.language
458 return fixed === 'zh' || fixed === 'en' ? fixed : this.auto
459 }
460
461 /** True when this project is not being recorded (read again after a wait, where the type system would still say otherwise). */
462 private get isOff(): boolean {
463 return this.mode === 'off'
464 }
465
466 get asciiGlyphs(): boolean {
467 return this.config?.display.asciiGlyphs === true
468 }
469
470 get statusInterval(): number | null {
471 return this.config?.status?.intervalSec ?? null
472 }
473
474 /** What follows a change of mode or config: tools, state, prompt. Call inside `exclusive`. */
475 private async applyMode($: Dollar): Promise<void> {
476 await this.registerTools($)
477 await this.selectTree($)
478 if (this.mode !== 'off') await this.syncJournal($)
479 const lang = this.effectiveLang()
480 const retitle = lang !== this.lang
481 this.lang = lang
482 await this.syncNow($)
483 await this.publishAll($)
484 this.refreshPrompt()
485 if (retitle) await this.retitle($)
486 void this.translate($).catch(() => undefined)
487 }
488
489 /**
490 * Points the journal at the tree config.json names. A tree that is not in this folder (yet: its events
491 * may still be on their way through git) leaves the journal where it is, and the poll looks again.
492 * True when the journal was switched. Call inside `exclusive`.
493 */
494 private async selectTree($: Dollar): Promise<boolean> {
495 const wanted = this.config?.tree ?? MAIN
496 const here = this.journal!
497 if (wanted === here.tree) {
498 this.treeProblem = null
499 return false
500 }
501 const candidate = this.journalOf(wanted)
502 if (wanted !== MAIN) {
503 let complete = false
504 try {
505 await this.bounded(candidate.sync(), SYNC_TIMEOUT_MS)
506 complete = candidate.model().fork !== null
507 } catch {
508 // a folder that cannot be read is not a tree that is not there; the poll looks again
509 }
510 if (!complete) {
511 if (this.treeProblem !== wanted) {
512 this.treeProblem = wanted
513 $.ui.toast(t(this.lang, 'tree.fellBack', { name: wanted, current: here.tree }))
514 }
515 return false
516 }
517 }
518 this.journal = candidate
519 this.published = null
520 this.treeProblem = null
521 if (!this.quietSwitch) $.ui.toast(t(this.lang, 'tree.followed', { name: wanted }))
522 return true
523 }
524
525 /** After this session changed config.json itself: take the change in without announcing it. Call inside `exclusive`. */
526 private async followConfig($: Dollar): Promise<void> {
527 this.quietSwitch = true
528 try {
529 if (await this.readConfig()) await this.applyMode($)
530 } finally {
531 this.quietSwitch = false
532 }
533 }
534
535 private async publishAll($: Dollar): Promise<void> {
536 const project = { root: this.root, mode: this.mode, config: this.config, configError: this.configError, tree: this.journal!.tree }
537 await $.state.project.update(() => project)
538 await $.state.lang.update(() => this.lang)
539 await $.state.scheme.update(() => this.scheme)
540 await this.publish($)
541 await this.publishTm($)
542 }
543
544 /** The journal of a tree of this project; one is made (and has read nothing yet) for a tree not met before. */
545 private journalOf(tree: string): Journal {
546 let j = this.journals.get(tree)
547 if (j === undefined) {
548 j = this.journal!.sibling(tree)
549 this.journals.set(tree, j)
550 }
551 return j
552 }
553
554 /** Whether the pane can show this tree: it is the first one or the one recorded into, or it is there whole (its `fork` event is written). */
555 private hasTree(tree: string): boolean {
556 if (tree === MAIN || tree === this.journal?.tree) return true
557 const j = this.journals.get(tree)
558 return j !== undefined && j.model().fork !== null
559 }
560
561 /** The tree the pane shows: the one the person chose to look at, else the one recorded into (also when the chosen one is gone). */
562 private async viewedName($: Dollar): Promise<string> {
563 const ui = await $.state.ui.read()
564 if (ui.tree === null) return this.journal!.tree
565 if (this.hasTree(ui.tree)) return ui.tree
566 const gone = ui.tree
567 await $.state.ui.update(u => (u.tree === gone ? { ...u, tree: null, editing: null } : u))
568 return this.journal!.tree
569 }
570
571 /** Puts the trees, and the model of the tree the pane shows, into `$.state` when they changed. */
572 private async publish($: Dollar): Promise<void> {
573 if (this.mode === 'off') {
574 this.published = null
575 return
576 }
577 await this.publishTrees($)
578 const tree = await this.viewedName($)
579 const model = this.journalOf(tree).model()
580 if (this.published !== null && this.published.tree === tree && this.published.model === model) return
581 this.published = { tree, model }
582 await $.state.model.update(() => model)
583 }
584
585 /** The facts about every tree (for the tabs and the overview), worked out again only for a tree that changed. */
586 private async publishTrees($: Dollar): Promise<void> {
587 const here = this.journal!.tree
588 const infos: DtTreeInfo[] = []
589 for (const [name, j] of this.journals) {
590 const model = j.model()
591 // a copy that is not finished (no `fork` event yet) is not a tree to show
592 if (name !== MAIN && name !== here && model.fork === null) continue
593 let known = this.infos.get(name)
594 if (known === undefined || known.model !== model) {
595 known = { model, info: treeInfoOf(name, model, j.entries()) }
596 this.infos.set(name, known)
597 }
598 infos.push(known.info)
599 }
600 for (const name of [...this.infos.keys()]) if (!this.journals.has(name)) this.infos.delete(name)
601 const ordered = orderTrees(infos).slice(0, MAX_TREES)
602 const before = this.publishedTrees
603 if (before !== null && before.length === ordered.length && before.every((x, k) => x === ordered[k])) return
604 this.publishedTrees = ordered
605 await $.state.trees.update(() => ordered)
606 }
607
608 /**
609 * Looks at the other trees of the project: which there are, and what each holds now (each tree's folder is read
610 * in a turn of its own, so that a tool call waits for one tree and not for all). `locked` is for a caller that is
611 * already inside `exclusive`. Gives up when `budgetMs` has passed; the next round carries on.
612 */
613 private async syncTrees($: Dollar, budgetMs: number, locked = false): Promise<void> {
614 if (this.treesBusy || this.mode === 'off') return
615 this.treesBusy = true
616 const turn = <T>(fn: () => Promise<T>): Promise<T> => (locked ? fn() : this.exclusive(fn))
617 const until = Date.now() + budgetMs
618 try {
619 const names = await turn(() => this.listOthers())
620 for (const name of names) {
621 if (Date.now() > until) break
622 await turn(() => this.syncOther(name))
623 }
624 } catch {
625 // the next round looks again
626 } finally {
627 this.treesBusy = false
628 }
629 }
630
631 /** The names of the trees of the project other than the one recorded into; the journals of trees that are gone are dropped. */
632 private async listOthers(): Promise<string[]> {
633 const here = this.journal
634 if (here === null || this.mode === 'off') return []
635 let names: string[]
636 try {
637 names = await this.bounded(here.listTrees(), SYNC_TIMEOUT_MS)
638 } catch {
639 // a folder that cannot be read says nothing about which trees there are: what is known stays
640 return [...this.journals.keys()].filter(n => n !== here.tree)
641 }
642 const wanted = new Set<string>([MAIN, here.tree, ...names])
643 for (const name of [...this.journals.keys()]) if (!wanted.has(name)) this.journals.delete(name)
644 return [...wanted].filter(n => n !== here.tree)
645 }
646
647 /** Reads the events of one tree that were not read yet (in a turn, so never at the same time as a write to it). */
648 private async syncOther(name: string): Promise<void> {
649 if (this.mode === 'off' || name === this.journal?.tree) return
650 try {
651 await this.bounded(this.journalOf(name).sync(), SYNC_TIMEOUT_MS)
652 } catch {
653 // the last good model of that tree stays
654 }
655 }
656
657 /** After a write: refresh the derived snapshot and the pane. Call inside `exclusive`. */
658 private async afterWrite($: Dollar): Promise<void> {
659 try {
660 await this.journal!.writeTree(this.config?.title ?? baseName(this.root), this.nowMs)
661 } catch {
662 // tree.json is a convenience for other tools; the events are the record
663 }
664 await this.publish($)
665 }
666
667 /** The tools for the current mode: only `decision_log_start` while off, the rest once on. */
668 private async registerTools($: Dollar): Promise<void> {
669 const want: ToolName[] = this.mode === 'off' ? ['decision_log_start'] : [...WRITE_TOOLS]
670 for (const name of want) {
671 if (this.registered.has(name)) continue
672 await $.tool.register(SPECS[name])
673 this.registered.add(name)
674 }
675 }
676
677 // ---------------------------------------------------------------- the poll
678
679 async tick($: Dollar): Promise<void> {
680 if (!this.ready || this.ticking) return // one round at a time: a slow disk must not pile rounds up
681 this.ticking = true
682 try {
683 // a reload built a newer object: leave the poll to it
684 if (!(await this.holds($))) {
685 this.stop()
686 return
687 }
688 await this.exclusive(async () => {
689 await this.clock($)
690 if (await this.refreshConfig($, true)) return
691 // the events of the tree config.json names may have arrived (a git pull)
692 if (this.treeProblem !== null && (await this.selectTree($))) await this.applyMode($)
693 else if (this.mode !== 'off' && (await this.syncJournal($))) await this.publish($)
694 // renditions another session (or a teammate's git pull) brought
695 if (this.mode !== 'off') await this.syncTm($)
696 })
697 // the other trees: a tree opened in another session, events of a tree that is not the one recorded into
698 if (this.mode !== 'off') {
699 await this.syncTrees($, TREES_POLL_MS)
700 await this.exclusive(() => this.publish($))
701 }
702 // what still lacks its other-language version is asked for in the background; this poll does not wait for the answer
703 void this.translate($).catch(() => undefined)
704 await this.syncScheme($)
705 await this.maybeAutoOpen($)
706 await this.maybeRunStatus($)
707 await this.redrawClock($)
708 } catch {
709 // the next tick tries again
710 } finally {
711 this.ticking = false
712 }
713 }
714
715 /** Ages ("updated 3 min ago", "stale 2 min") only change with the clock: draw the pane again every minute. */
716 private async redrawClock($: Dollar): Promise<void> {
717 if (this.mode === 'off' || this.nowMs - this.lastDraw < 60_000) return
718 if (!(await this.isPaneShown($))) return
719 this.lastDraw = this.nowMs
720 $.ui.invalidate()
721 }
722
723 // ---------------------------------------------------------------- prompt and language
724
725 private refreshPrompt(): void {
726 const idx = this.mode === 'recording' && this.journal !== null ? buildIndex(this.journal.model()) : null
727 this.promptText = sectionText(this.mode, idx, this.treeNote())
728 }
729
730 /** Which tree this is, for the model, when it is not the main one. */
731 private treeNote(): { name: string; from: string } | null {
732 const j = this.journal
733 if (j === null || j.tree === MAIN) return null
734 const fork = j.model().fork
735 return { name: j.tree, from: fork === null ? '' : forkText('en', fork) }
736 }
737
738 /** The section to add to the system prompt, or null before the project is known. */
739 section(): { id: string; text: string; scope: 'session' } | null {
740 return this.ready ? { id: SECTION_ID, text: this.promptText, scope: 'session' } : null
741 }
742
743 /** A turn starts: pick up other sessions' decisions and freeze this turn's section. */
744 async onTurnStart($: Dollar): Promise<void> {
745 if (!this.ready) return
746 try {
747 await this.holds($)
748 await this.exclusive(async () => {
749 if (this.mode !== 'off') await this.syncJournal($)
750 await this.publish($)
751 this.refreshPrompt()
752 })
753 } catch {
754 // keep the previous section
755 }
756 }
757
758 /**
759 * A prompt arrived: while the language is auto, follow the one the person writes in. `origin` is where the engine says
760 * it came from; a notification, a schedule, another session or another plugin (this one's `/decide init` included) is
761 * not the person's own words, and says nothing about their language.
762 */
763 async onPrompt($: Dollar, text: string, origin?: string): Promise<void> {
764 if (!this.ready) return
765 if (origin !== undefined && NOT_THE_PERSON.has(origin)) return
766 await this.holds($)
767 if (this.config?.display.language !== 'auto' && this.config !== null) return
768 if (text.trimStart().startsWith('/')) return
769 const found = detectLanguage(text)
770 if (found === null || found === this.auto) return
771 this.auto = found
772 await $.store.set(`lang:${this.root}`, found).catch(() => undefined)
773 await this.followLang($)
774 }
775
776 /** The person chose a language with the switch of the pane (the `EN 中` strip or the `l` key). */
777 async setLang($: Dollar, lang: DtLang): Promise<void> {
778 await this.ensure($)
779 this.pinned = lang
780 await $.store.set(`pin:${this.root}`, lang).catch(() => undefined)
781 await this.followLang($)
782 }
783
784 /** Forgets the choice made with the switch (`/decide lang` has just set the language for everybody). */
785 private async unpin($: Dollar): Promise<void> {
786 if (this.pinned === null) return
787 this.pinned = null
788 await $.store.set(`pin:${this.root}`, '').catch(() => undefined)
789 }
790
791 /** Brings the pane's language in line with the rules above; the pane's tab is renamed when it changed. */
792 private async followLang($: Dollar): Promise<void> {
793 const lang = this.effectiveLang()
794 if (lang === this.lang) return
795 this.lang = lang
796 await $.state.lang.update(() => lang)
797 await this.retitle($)
798 // (reading in Chinese wants Chinese versions of what was written in English)
799 void this.translate($).catch(() => undefined)
800 }
801
802 private async retitle($: Dollar): Promise<void> {
803 try {
804 const panes = await $.ui.panes()
805 if (panes.some(p => p.id === PANE_ID)) await $.ui.open({ id: PANE_ID, title: t(this.lang, 'pane.tab'), columns: 72 })
806 } catch {
807 // a pane that cannot be retitled keeps its old name
808 }
809 }
810
811 // ---------------------------------------------------------------- translation
812
813 /** Reads the renditions not seen yet (the poll, and right before the translator asks for more). Call inside `exclusive`. True when the memory changed. */
814 private async syncTm($: Dollar): Promise<boolean> {
815 const store = this.tmStore
816 if (store === null) return false
817 try {
818 await this.bounded(store.sync(), SYNC_TIMEOUT_MS)
819 } catch {
820 // what the store holds stays; the next poll looks again
821 }
822 return this.publishTm($)
823 }
824
825 /** Puts the memory into `$.state` when it is not there yet. */
826 private async publishTm($: Dollar): Promise<boolean> {
827 const tm = this.tmStore?.tm() ?? EMPTY_TM
828 if (this.publishedTm === tm) return false
829 this.publishedTm = tm
830 await $.state.tm.update(() => tm)
831 return true
832 }
833
834 /** The texts that still lack a rendition, the tree the pane shows first: the overview draws the other trees' titles too, and English is wanted for every text. */
835 private wantedTexts(limit: number): Want[] {
836 if (this.mode === 'off' || this.journal === null) return []
837 const models: DtModel[] = []
838 const take = (m: DtModel | undefined): void => {
839 if (m !== undefined && !models.includes(m)) models.push(m)
840 }
841 take(this.published?.model)
842 take(this.journal.model())
843 for (const [name, j] of this.journals) if (name === MAIN || j.model().fork !== null) take(j.model())
844 const skip = (w: Want): boolean => (this.tries.get(`${w.lang}\0${w.text}`) ?? 0) >= MAX_TEXT_TRIES
845 return wantedRenditions(models, this.tmStore?.tm() ?? EMPTY_TM, { zh: this.lang === 'zh', limit, skip })
846 }
847
848 /** Puts the number of texts waiting for a rendition into `$.state` (the pane says so), when it changed. */
849 private async setTranslating($: Dollar, n: number): Promise<void> {
850 if (this.publishedTranslating === n) return
851 this.publishedTranslating = n
852 await $.state.translating.update(() => n)
853 }
854
855 /** Lets the translator try again from scratch (the person switched translating on). */
856 private forgetFailures(): void {
857 this.translateFailures = 0
858 this.translateRetryAt = 0
859 this.translateStuck = false
860 this.translateTold = false
861 this.tries.clear()
862 }
863
864 /** One request to the model: the text it answered with, or why there is none. */
865 private async askModel($: Dollar, request: ReturnType<typeof requestFor>): Promise<{ text: string } | { why: string; permanent: boolean }> {
866 try {
867 const reply: ModelCompleteResult = await $.model.complete(request, { signal: this.abort.signal })
868 return reply.isAnswered ? { text: reply.text } : failureOf(reply)
869 } catch (err) {
870 // (the engine only refuses a request it will never send: a model that is not allowed)
871 return { why: firstLine(err), permanent: true }
872 }
873 }
874
875 private noteFailure($: Dollar, why: string, permanent: boolean): void {
876 this.translateFailures += 1
877 this.translateRetryAt = this.nowMs + retryDelay(this.translateFailures)
878 if (permanent) this.translateStuck = true
879 if ((permanent || this.translateFailures >= TELL_AFTER) && !this.translateTold) {
880 this.translateTold = true
881 $.ui.toast(t(this.lang, 'tr.failed', { error: why }))
882 }
883 }
884
885 /**
886 * In the background: asks the session's model for the versions the pane lacks (English for every text written in Chinese,
887 * Chinese for English text while the pane reads in Chinese), one request of a few texts at a time, until nothing is wanted.
888 * A model call is long, so none of this holds `exclusive` while it waits; only reading and writing the memory does.
889 * Does nothing when `display.translate` is off. A failure is waited out (30 s, 2 min, 10 min) and the person is told once;
890 * one that will not clear (a model that is not allowed, no credit) ends the attempts for this session.
891 */
892 async translate($: Dollar): Promise<void> {
893 if (!this.ready || this.translatorBusy) return
894 this.translatorBusy = true
895 try {
896 for (let round = 0; round < MAX_TRANSLATE_ROUNDS; round++) {
897 if (this.timer === null) return
898 const owner = await $.state.owner.read()
899 if (owner !== '' && owner !== this.token) return
900 await this.clock($)
901 const off = this.mode === 'off' || this.config?.display.translate === false
902 const wanted = off ? [] : this.wantedTexts(WANT_WINDOW)
903 // (while it keeps failing the pane does not claim to be translating)
904 await this.setTranslating($, this.translateStuck || this.translateFailures >= TELL_AFTER ? 0 : wanted.length)
905 if (wanted.length === 0 || this.translateStuck || this.nowMs < this.translateRetryAt) return
906
907 const ask = nextBatch(wanted)
908 if (ask === null) return
909 // another session, or a teammate's pull, may have brought some of it meanwhile
910 if (await this.exclusive(() => this.syncTm($))) continue
911
912 const outcome = await this.askModel($, requestFor(ask))
913 await this.clock($)
914 if (this.timer === null) return
915 if ('why' in outcome) {
916 this.noteFailure($, outcome.why, outcome.permanent)
917 continue
918 }
919 const read = readReply(ask, outcome.text)
920 if ('problem' in read) {
921 for (const text of ask.texts) this.tries.set(`${ask.to}\0${text}`, (this.tries.get(`${ask.to}\0${text}`) ?? 0) + 1)
922 this.noteFailure($, read.problem, false)
923 continue
924 }
925 for (const text of read.bad) this.tries.set(`${ask.to}\0${text}`, (this.tries.get(`${ask.to}\0${text}`) ?? 0) + 1)
926 if (read.pairs.length === 0) {
927 this.noteFailure($, 'the reply had no usable text', false)
928 continue
929 }
930 await this.exclusive(async () => {
931 await this.tmStore!.append(ask.to, read.pairs, { actor: 'model', via: 'translator' }, this.nowMs)
932 await this.publishTm($)
933 })
934 this.translateFailures = 0
935 this.translateRetryAt = 0
936 this.translateTold = false
937 }
938 } catch {
939 // the next poll tries again
940 } finally {
941 this.translatorBusy = false
942 }
943 }
944
945 // ---------------------------------------------------------------- the pane opens
946
947 async openPane($: Dollar, focus: boolean): Promise<boolean> {
948 try {
949 const res = await $.ui.open({ id: PANE_ID, title: t(this.lang, 'pane.tab'), columns: 72, rows: 24, ...(focus ? { focus: true as const } : {}) })
950 return res.isPlaced
951 } catch {
952 return false
953 }
954 }
955
956 /** The Pane/AbovePrompt hooks tell us what the surface looks like. */
957 seeViewport(viewport: { isFullscreen?: boolean } | undefined): void {
958 if (viewport?.isFullscreen === true) this.isFullscreen = true
959 }
960
961 /** When the project is on, the screen is fullscreen and autoOpen is true, open once, unasked. */
962 private async maybeAutoOpen($: Dollar): Promise<void> {
963 if (this.autoOpened || !this.isFullscreen || this.mode === 'off') return
964 if (this.config?.display.autoOpen !== true) return
965 this.autoOpened = true
966 await this.openPane($, false)
967 }
968
969 private async isPaneShown($: Dollar): Promise<boolean> {
970 try {
971 return (await $.ui.panes()).some(p => p.id === PANE_ID && p.isShown && p.isPlaced)
972 } catch {
973 return false
974 }
975 }
976
977 // ---------------------------------------------------------------- tools
978
979 async toolCall($: Dollar, name: ToolName, args: Loose): Promise<ToolAnswer> {
980 try {
981 await this.ensure($)
982 if (name === 'decision_log_start') return await this.startTool($, args)
983 if (name === 'decision_tree') return await this.readTool($, args)
984 if (name === 'decision_new_tree' || name === 'decision_use_tree') return await this.treeTool($, name, args)
985 return await this.writeTool($, name, args)
986 } catch (err) {
987 return { deny: `decision-tree: ${firstLine(err)}. The record may or may not have been changed; call decision_tree to check before trying again.` }
988 }
989 }
990
991 private async startTool($: Dollar, args: Loose): Promise<ToolAnswer> {
992 const title = typeof args.title === 'string' ? oneLine(args.title) : ''
993 if (title === '') return { deny: 'title is required: the name of the project' }
994 const rootTitle = typeof args.rootTitle === 'string' ? args.rootTitle : undefined
995 const argv = Array.isArray(args.statusArgv) ? args.statusArgv.filter((a): a is string => typeof a === 'string' && a !== '') : undefined
996
997 return this.exclusive(async () => {
998 await this.refreshConfig($)
999 if (this.configError !== null) {
1000 return { deny: `.claude/decisions/config.json exists but cannot be used (${this.configError}). Ask the user to fix or delete it.` }
1001 }
1002 if (this.mode === 'recording') return { result: t(this.lang, 'r.alreadyOn') }
1003 if (this.mode === 'paused') return { result: t(this.lang, 'r.alreadyPaused') }
1004 const done = await this.begin($, 'model', title, rootTitle, argv)
1005 if (!done.ok) return { deny: done.error }
1006 return { result: done.text }
1007 })
1008 }
1009
1010 /**
1011 * Creates the record and switches this session on. The pane takes the keyboard when the person asked for
1012 * the start (the default) and `focus` does not say otherwise. Call inside `exclusive`.
1013 */
1014 private async begin($: Dollar, actor: DtActor, title: string, rootTitle?: string, argv?: string[], focus: boolean = actor === 'user'): Promise<Done> {
1015 const ctx = await this.ctx($, actor)
1016 const done = await startRecord(ctx, { title, ...(rootTitle !== undefined ? { rootTitle } : {}), ...(argv !== undefined && argv.length > 0 ? { statusArgv: argv } : {}) })
1017 if (!done.ok) return done
1018 await this.readConfig()
1019 await this.applyMode($)
1020 await this.afterWrite($)
1021 await this.openPane($, focus)
1022 const note = (await isIgnoredByGit($, this.root)) ? ` ${t(this.lang, 'r.gitignore')}` : ''
1023 return { ok: true, text: done.text + note, ...(done.id !== undefined ? { id: done.id } : {}) }
1024 }
1025
1026 private async readTool($: Dollar, args: Loose): Promise<ToolAnswer> {
1027 if (this.mode === 'off') return { deny: NOT_RECORDING }
1028 return this.exclusive(async () => {
1029 // the project may have been moved to another tree since the last poll: read the one it is on now
1030 await this.refreshConfig($)
1031 if (this.mode === 'off') return { deny: NOT_RECORDING }
1032 await this.syncJournal($)
1033 await this.publish($)
1034 if (args.trees === true) return answer(await treeList(await this.ctx($, 'model')))
1035 if (typeof args.log === 'number' && Number.isFinite(args.log)) return answer(await treeLog(await this.ctx($, 'model'), Math.floor(args.log)))
1036 const note = this.treeNote()
1037 const head = note === null ? '' : `Tree: ${note.name}${note.from === '' ? '' : ` (${note.from})`}. decision_tree with trees: true lists the project's trees.\n`
1038 return { result: head + treeReport(buildIndex(this.journal!.model()), args) }
1039 })
1040 }
1041
1042 /** The tools that open a tree and switch to one: the model only does either when the user asked. */
1043 private async treeTool($: Dollar, name: 'decision_new_tree' | 'decision_use_tree', args: Loose): Promise<ToolAnswer> {
1044 if (this.mode === 'off') return { deny: NOT_RECORDING }
1045 if (this.mode === 'paused') return { result: t(this.lang, 'r.pausedBlock') }
1046 const str = (x: unknown): string | undefined => (typeof x === 'string' && x.trim() !== '' ? x.trim() : undefined)
1047 const op: TreeOp =
1048 name === 'decision_use_tree'
1049 ? { kind: 'use', name: str(args.name) ?? '' }
1050 : {
1051 kind: 'new',
1052 input: {
1053 name: str(args.name) ?? '',
1054 ...(str(args.from) !== undefined ? { from: str(args.from)! } : {}),
1055 ...(str(args.at) !== undefined ? { at: str(args.at)! } : {}),
1056 ...(str(args.title) !== undefined ? { title: str(args.title)! } : {}),
1057 ...(args.empty === true ? { empty: true } : {}),
1058 ...(args.stay === true ? { stay: true } : {}),
1059 },
1060 }
1061 return this.exclusive(async () => {
1062 await this.refreshConfig($)
1063 return answer(await this.treeOp($, await this.ctx($, 'model'), op))
1064 }, TREE_TIMEOUT_MS)
1065 }
1066
1067 /** The tree operations, the same for a command and a tool. Call inside `exclusive`. */
1068 private async treeOp($: Dollar, ctx: Ctx, op: TreeOp): Promise<Done> {
1069 await this.syncJournal($)
1070 switch (op.kind) {
1071 case 'list':
1072 return treeList(ctx)
1073 case 'log':
1074 return treeLog(ctx, op.limit)
1075 case 'use': {
1076 const done = await useTree(ctx, op.name)
1077 if (done.ok) await this.followConfig($)
1078 return done
1079 }
1080 case 'new': {
1081 const done = await newTree(ctx, op.input)
1082 if (done.ok) {
1083 await this.followConfig($)
1084 // (a tree that was opened without switching to it is not in the list yet)
1085 await this.syncTrees($, TREES_OPEN_MS, true)
1086 await this.publish($)
1087 }
1088 return done
1089 }
1090 }
1091 }
1092
1093 private async writeTool($: Dollar, name: ToolName, args: Loose): Promise<ToolAnswer> {
1094 if (this.mode === 'off') return { deny: NOT_RECORDING }
1095 if (this.mode === 'paused') return { result: t(this.lang, 'r.pausedBlock') }
1096 return this.exclusive(async () => {
1097 // what is recorded goes to the tree the project is on now (another session may have switched it
1098 // since the last poll), and not at all if it was paused or switched off meanwhile
1099 await this.refreshConfig($)
1100 if (this.mode === 'off') return { deny: NOT_RECORDING }
1101 if (this.mode === 'paused') return { result: t(this.lang, 'r.pausedBlock') }
1102 const ctx = await this.ctx($, 'model')
1103 const done = name === 'decision_record' ? await recordNode(ctx, args) : name === 'decision_update' ? await updateNode(ctx, args) : await noteNode(ctx, args)
1104 await this.afterWrite($)
1105 return answer(done)
1106 })
1107 }
1108
1109 // ---------------------------------------------------------------- commands
1110
1111 async command($: Dollar, name: string, args: string): Promise<string> {
1112 try {
1113 await this.ensure($)
1114 if (name === 'tree') return await this.treeCommand($)
1115 return await this.decideCommand($, parseDecide(args))
1116 } catch (err) {
1117 return t(this.lang, 'c.failed', { error: firstLine(err) })
1118 }
1119 }
1120
1121 private async treeCommand($: Dollar): Promise<string> {
1122 const placed = await this.openPane($, true)
1123 if (!placed) return this.treeAsText($)
1124 void this.maybeRunStatus($, true).catch(() => undefined)
1125 return t(this.lang, 'r.openedPane')
1126 }
1127
1128 /** A screen with no room for the pane gets the tree as text. */
1129 private async treeAsText($: Dollar): Promise<string> {
1130 const head = t(this.lang, 'r.noPane')
1131 if (this.mode === 'off') return `${head}\n${t(this.lang, 'pane.empty')}`
1132 const report = await this.exclusive(async () => {
1133 await this.syncJournal($)
1134 return treeReport(buildIndex(this.journal!.model()), {})
1135 })
1136 return `${head}\n${report}`
1137 }
1138
1139 /** `/decide tree ...` (and its short forms): the person's own, so they work while recording is paused. */
1140 private async treeCommand2($: Dollar, op: TreeOp): Promise<string> {
1141 if (this.mode === 'off') return t(this.lang, 'c.notStarted')
1142 return this.exclusive(async () => {
1143 await this.refreshConfig($)
1144 if (this.mode === 'off') return t(this.lang, 'c.notStarted')
1145 const done = await this.treeOp($, await this.ctx($, 'user'), op)
1146 if (!done.ok) return done.error
1147 // opening or switching works while paused (it is the person's), but what comes next is not recorded
1148 const paused = this.mode === 'paused' && (op.kind === 'new' || op.kind === 'use')
1149 return paused ? `${done.text} ${t(this.lang, 'tree.pausedNote')}` : done.text
1150 }, TREE_TIMEOUT_MS)
1151 }
1152
1153 /**
1154 * `/decide init`: the person wants the first points of the tree found for them. Recording is switched on
1155 * when it is off (the pane opens without taking the keyboard: the model is about to work), then the model
1156 * is handed the job of looking through the project. Only an empty tree is set up; two inits in a row are
1157 * caught by the job's own first step, which reads the tree before it records anything.
1158 */
1159 private async initCommand($: Dollar, title: string): Promise<string> {
1160 const plan = await this.exclusive(async (): Promise<{ text: string; ask?: string }> => {
1161 await this.refreshConfig($)
1162 if (this.configError !== null) return { text: t(this.lang, 'pane.empty.config', { error: this.configError }) }
1163 if (this.mode === 'paused') return { text: t(this.lang, 'c.initPaused') }
1164 let head = ''
1165 if (this.mode === 'off') {
1166 const done = await this.begin($, 'user', title !== '' ? title : baseName(this.root), undefined, undefined, false)
1167 if (!done.ok) return { text: done.error }
1168 head = done.text
1169 } else {
1170 await this.syncJournal($)
1171 const n = this.journal!.model().nodes.filter(node => node.retracted === undefined).length
1172 if (n > 0) return { text: t(this.lang, 'c.initNotEmpty', { n }) }
1173 // (a start opens the pane itself; here it may have been closed, and it is where the tree fills in)
1174 await this.openPane($, false)
1175 }
1176 const name = title !== '' ? title : (this.config?.title ?? baseName(this.root))
1177 return { text: head, ask: initPrompt({ title: name, write: this.initWrite() }) }
1178 })
1179 if (plan.ask === undefined) return plan.text
1180 this.sendInit($, plan.ask)
1181 const asked = t(this.lang, 'r.initAsked')
1182 return plan.text === '' ? asked : `${plan.text} ${asked}`
1183 }
1184
1185 /** What init asks the records to be written in: the language config.json fixes, or the one the person has been writing in. */
1186 private initWrite(): InitWrite {
1187 const fixed = this.config?.display.language
1188 if (fixed === 'zh' || fixed === 'en') return fixed
1189 // (nothing says an English `auto` was ever seen: the person may not have typed a word yet, so the model goes by what it knows of them)
1190 return this.auto === 'zh' ? 'zh' : 'follow'
1191 }
1192
1193 /**
1194 * Hands the model init's job as the person's own words. The prompt starts a turn of its own once the session
1195 * is idle, and the call answers when that turn starts, so nothing here waits for it. The engine refuses a
1196 * prompt submitted from inside the command that asks for it (the command holds the turn the prompt would wait
1197 * on), so the first attempt is made a moment after the command is done; a refusal is tried again later, and
1198 * told in a toast when the last attempt fails too, or when another plugin's hook drops the prompt.
1199 */
1200 private sendInit($: Dollar, text: string): void {lib/state.ts 40 lines1// The values this mod keeps in `$.state`: what the pane draws from. They live in
2// the session (they survive a hot reload) and are never written to disk.
3// The types are the contract in ../types/index.d.ts. The atoms themselves are
4// declared in hooks/register.tsx, where the engine can list them; these are
5// their initial values.
6
7import type { DtModel, DtNow, DtProject, DtTm, DtTreeInfo, DtUi } from '../types'
8
9export const EMPTY_MODEL: DtModel = {
10 isStarted: false,
11 startedAt: null,
12 nodes: [],
13 warnings: [],
14 warningCount: 0,
15 eventCount: 0,
16 lastEventAt: null,
17 fork: null,
18}
19
20export const EMPTY_PROJECT: DtProject = { root: '', mode: 'off', config: null, configError: null, tree: 'main' }
21
22export const EMPTY_UI: DtUi = {
23 selected: null,
24 expanded: [],
25 collapsed: [],
26 filter: 'default',
27 showNow: true,
28 editing: null,
29 view: 'tree',
30 tree: null,
31 pick: null,
32 newTree: null,
33}
34
35export const EMPTY_NOW: DtNow = { approval: 'none', argv: [], data: null, fetchedAt: null, error: null }
36
37export const EMPTY_TM: DtTm = { en: {}, zh: {} }
38
39export const EMPTY_TREES: DtTreeInfo[] = []
40lib/palettes.ts 223 lines1// Colour schemes for the pane.
2//
3// The pane paints with the theme keys of Claude Code (`success`, `error`, `ide` ...), so it follows whatever
4// theme the person has. A scheme replaces the accent colours only: the statuses, the mainline, the coloured
5// words, the markers. Text, grey and the selection band stay the terminal's own, so the pane still sits in
6// the person's terminal. The layout never knows about schemes; `paintFor(scheme)` is applied to the colours
7// just before they are drawn.
8//
9// A scheme has seven accent slots, and each slot stands in for the theme keys it is named after:
10// red error rejected, problems
11// orange claude the model; the third tree
12// yellow warning numbers, things to look at
13// green success adopted, quotes, recording
14// cyan planMode technical words; the second tree
15// blue ide, suggestion, remember, permission topics, file names, in progress, markers
16// purple merged the mainline; the first tree
17//
18// The colours of each scheme are the accent colours its authors publish (see THIRD_PARTY.md). A scheme made
19// for a light terminal and one made for a dark terminal are separate entries, because a mod cannot ask the
20// terminal which it is; the person picks the one that sits well in theirs.
21
22export const SLOTS = ['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple'] as const
23export type Slot = (typeof SLOTS)[number]
24export type Accents = Readonly<Record<Slot, string>>
25
26/** The theme key each slot stands in for (the first key of a slot is the one its swatch shows). */
27export const SLOT_KEYS: Readonly<Record<Slot, readonly string[]>> = {
28 red: ['error'],
29 orange: ['claude'],
30 yellow: ['warning'],
31 green: ['success'],
32 cyan: ['planMode'],
33 blue: ['ide', 'suggestion', 'remember', 'permission'],
34 purple: ['merged'],
35}
36
37const KEYMAP: Readonly<Record<string, Slot>> = Object.fromEntries(
38 (Object.entries(SLOT_KEYS) as [Slot, readonly string[]][]).flatMap(([slot, keys]) => keys.map(key => [key, slot] as const)),
39)
40
41export type SchemeTone = 'dark' | 'light' | 'any'
42
43export type Scheme = {
44 /** What a person types and what is remembered. */
45 readonly id: string
46 /** What the picker shows: the name its authors give it. */
47 readonly name: string
48 /** What the scheme is like, in the two languages of the pane. */
49 readonly note: { readonly en: string; readonly zh: string }
50 /** The kind of terminal it was made for. */
51 readonly tone: SchemeTone
52 /** The seven colours; null for the scheme that leaves the theme keys alone. */
53 readonly accents: Accents | null
54}
55
56function accents(red: string, orange: string, yellow: string, green: string, cyan: string, blue: string, purple: string): Accents {
57 return { red, orange, yellow, green, cyan, blue, purple }
58}
59
60export const DEFAULT_SCHEME = 'terminal'
61
62export const SCHEMES: readonly Scheme[] = [
63 {
64 id: 'terminal',
65 name: 'Terminal',
66 note: { en: "your terminal's own 16 colors", zh: '用 terminal 自带的 16 色' },
67 tone: 'any',
68 // The names of the 16 ANSI colours: whatever the terminal's own theme makes of them. The same pairs Claude Code's
69 // ANSI themes use, except that blue is the bright one on every terminal (the plain one is dark on a black screen).
70 accents: accents('red', 'redBright', 'yellow', 'green', 'cyan', 'blueBright', 'magenta'),
71 },
72 {
73 id: 'theme',
74 name: 'Theme',
75 note: { en: "follows Claude Code's own theme", zh: '跟随 Claude Code 的 theme' },
76 tone: 'any',
77 accents: null,
78 },
79 {
80 id: 'one-dark',
81 name: 'One Dark',
82 note: { en: 'Atom, for dark terminals', zh: 'Atom 的深色' },
83 tone: 'dark',
84 accents: accents('#e06c75', '#d19a66', '#e5c07b', '#98c379', '#56b6c2', '#61afef', '#c678dd'),
85 },
86 {
87 id: 'one-light',
88 name: 'One Light',
89 note: { en: 'Atom, for light terminals', zh: 'Atom 的浅色' },
90 tone: 'light',
91 accents: accents('#e45649', '#986801', '#c18401', '#50a14f', '#0184bc', '#4078f2', '#a626a4'),
92 },
93 {
94 id: 'nord',
95 name: 'Nord',
96 note: { en: 'cool and muted, for dark terminals', zh: '冷色、低饱和,适合深色' },
97 tone: 'dark',
98 accents: accents('#bf616a', '#d08770', '#ebcb8b', '#a3be8c', '#88c0d0', '#81a1c1', '#b48ead'),
99 },
100 {
101 id: 'gruvbox-dark',
102 name: 'Gruvbox Dark',
103 note: { en: 'warm and retro', zh: '暖色、复古' },
104 tone: 'dark',
105 accents: accents('#fb4934', '#fe8019', '#fabd2f', '#b8bb26', '#8ec07c', '#83a598', '#d3869b'),
106 },
107 {
108 id: 'gruvbox-light',
109 name: 'Gruvbox Light',
110 note: { en: 'warm and retro', zh: '暖色、复古' },
111 tone: 'light',
112 accents: accents('#9d0006', '#af3a03', '#b57614', '#79740e', '#427b58', '#076678', '#8f3f71'),
113 },
114 {
115 id: 'dracula',
116 name: 'Dracula',
117 note: { en: 'vivid, purple and pink, for dark terminals', zh: '鲜艳、紫粉,适合深色' },
118 tone: 'dark',
119 accents: accents('#ff5555', '#ffb86c', '#f1fa8c', '#50fa7b', '#8be9fd', '#bd93f9', '#ff79c6'),
120 },
121 {
122 id: 'tokyo-night',
123 name: 'Tokyo Night',
124 note: { en: 'blue and violet, soft', zh: '蓝紫、柔和' },
125 tone: 'dark',
126 accents: accents('#f7768e', '#ff9e64', '#e0af68', '#9ece6a', '#7dcfff', '#7aa2f7', '#bb9af7'),
127 },
128 {
129 id: 'tokyo-night-day',
130 name: 'Tokyo Night Day',
131 note: { en: 'blue and violet, soft, for light terminals', zh: '蓝紫、柔和,适合浅色' },
132 tone: 'light',
133 accents: accents('#f52a65', '#b15c00', '#8c6c3e', '#587539', '#007197', '#2e7de9', '#9854f1'),
134 },
135 {
136 id: 'catppuccin-mocha',
137 name: 'Catppuccin Mocha',
138 note: { en: 'pastel', zh: '粉彩' },
139 tone: 'dark',
140 accents: accents('#f38ba8', '#fab387', '#f9e2af', '#a6e3a1', '#94e2d5', '#89b4fa', '#cba6f7'),
141 },
142 {
143 id: 'catppuccin-latte',
144 name: 'Catppuccin Latte',
145 note: { en: 'pastel, for light terminals', zh: '粉彩,适合浅色' },
146 tone: 'light',
147 accents: accents('#d20f39', '#fe640b', '#df8e1d', '#40a02b', '#179299', '#1e66f5', '#8839ef'),
148 },
149 {
150 id: 'solarized',
151 name: 'Solarized',
152 note: { en: 'low contrast, classic; for dark and light', zh: '低对比、经典;深浅色通用' },
153 tone: 'any',
154 accents: accents('#dc322f', '#cb4b16', '#b58900', '#859900', '#2aa198', '#268bd2', '#6c71c4'),
155 },
156 {
157 id: 'monokai',
158 name: 'Monokai',
159 note: { en: 'high contrast, for dark terminals', zh: '高对比,适合深色' },
160 tone: 'dark',
161 accents: accents('#f92672', '#fd971f', '#f4bf75', '#a6e22e', '#a1efe4', '#66d9ef', '#ae81ff'),
162 },
163 {
164 id: 'github-dark',
165 name: 'GitHub Dark',
166 note: { en: 'neutral and familiar', zh: '中性、熟悉' },
167 tone: 'dark',
168 accents: accents('#ff7b72', '#ffa657', '#e3b341', '#7ee787', '#39c5cf', '#79c0ff', '#d2a8ff'),
169 },
170 {
171 id: 'github-light',
172 name: 'GitHub Light',
173 note: { en: 'neutral and familiar', zh: '中性、熟悉' },
174 tone: 'light',
175 accents: accents('#cf222e', '#953800', '#9a6700', '#116329', '#1b7c83', '#0550ae', '#8250df'),
176 },
177]
178
179const BY_ID = new Map(SCHEMES.map(s => [s.id, s]))
180
181export const SCHEME_IDS: readonly string[] = SCHEMES.map(s => s.id)
182
183/** The scheme with this id, or undefined. Case and surrounding space do not matter. */
184export function findScheme(id: string): Scheme | undefined {
185 return BY_ID.get(id.trim().toLowerCase().replace(/[\s_]+/g, '-'))
186}
187
188/** The scheme a stored value names; the default when it names none (a scheme removed in a later version, a typo in the store). */
189export function schemeOf(value: unknown): Scheme {
190 return (typeof value === 'string' ? findScheme(value) : undefined) ?? BY_ID.get(DEFAULT_SCHEME)!
191}
192
193/**
194 * The scheme to draw with on a surface. The terminal's own 16 colours are a terminal's: any other surface
195 * (the desktop app, an editor) draws the theme's.
196 */
197export function effectiveScheme(scheme: Scheme, surface: string | undefined): Scheme {
198 if (scheme.id === 'terminal' && surface !== undefined && surface !== 'terminal') return BY_ID.get('theme')!
199 return scheme
200}
201
202/** The slot a theme key stands in for, if it is one of the accent keys. */
203export function slotOf(key: string): Slot | undefined {
204 return Object.hasOwn(KEYMAP, key) ? KEYMAP[key] : undefined
205}
206
207/** The colour a scheme gives a color the layout names (a theme key stays as it is where the scheme leaves it alone). */
208export function paintColor(scheme: Scheme, color: string | undefined): string | undefined {
209 if (color === undefined || scheme.accents === null) return color
210 const slot = slotOf(color)
211 return slot === undefined ? color : scheme.accents[slot]
212}
213
214/** `paintColor` for one scheme, ready to hand around. */
215export function paintFor(scheme: Scheme): (color: string | undefined) => string | undefined {
216 return color => paintColor(scheme, color)
217}
218
219/** The seven colours the picker shows for a scheme, in slot order (theme keys for the scheme that follows the theme). */
220export function swatchOf(scheme: Scheme): string[] {
221 return SLOTS.map(slot => scheme.accents?.[slot] ?? SLOT_KEYS[slot][0]!)
222}
223lib/tools.ts 334 lines1// The tools the model sees: their names, the schemas, and the rules the
2// descriptions carry. The text is for the model, so it is English whatever
3// language the pane is in.
4
5import type { ToolSpec } from 'claude-code'
6
7import { STATUSES } from './events'
8import type { TreeIndex } from './tree'
9import { recentNodes, resolveRef, trunkNodes } from './tree'
10import { oneLine, truncate } from './width'
11import type { DtNode } from '../types'
12
13export const PLUGIN = 'decision-tree'
14export const TOOL_PREFIX = `mcp__${PLUGIN}__`
15
16export type ToolName =
17 | 'decision_log_start'
18 | 'decision_record'
19 | 'decision_update'
20 | 'decision_note'
21 | 'decision_tree'
22 | 'decision_new_tree'
23 | 'decision_use_tree'
24
25export const WRITE_TOOLS: readonly ToolName[] = ['decision_record', 'decision_update', 'decision_note', 'decision_tree', 'decision_new_tree', 'decision_use_tree']
26
27/** `mcp__decision-tree__decision_record`: the name the engine and the model use. */
28export type FullName = `mcp__decision-tree__${ToolName}`
29
30export function fullName(name: ToolName): FullName {
31 return `${TOOL_PREFIX}${name}` as FullName
32}
33
34/** The short tool name for a full `mcp__decision-tree__x` name, or null for any other tool. */
35export function shortName(full: string): ToolName | null {
36 if (!full.startsWith(TOOL_PREFIX)) return null
37 const name = full.slice(TOOL_PREFIX.length)
38 return (ALL_TOOLS as readonly string[]).includes(name) ? (name as ToolName) : null
39}
40
41const ALL_TOOLS: readonly ToolName[] = ['decision_log_start', ...WRITE_TOOLS]
42
43const KINDS = ['decision', 'protocol', 'design', 'milestone']
44const DECIDERS = ['user', 'gate', 'model']
45
46const EVIDENCE_ITEM = {
47 type: 'object',
48 required: ['label'],
49 properties: {
50 label: { type: 'string', description: 'What was measured or observed, e.g. "model A, run 0, after warm-up".' },
51 value: { type: 'string', description: 'The key number, e.g. "cold start -17.4%".' },
52 ci: { type: 'string', description: 'The interval, e.g. "[-19.8, -15.3]".' },
53 run: { type: 'string', description: 'The run name, e.g. "model_a_s0".' },
54 note: { type: 'string' },
55 source: { type: 'string', description: 'Path of the report or file the number comes from.' },
56 },
57}
58
59/** The English version of an evidence item (the words of it: what was measured, the key number as text, the remark). */
60const EN_EVIDENCE = {
61 type: 'object',
62 properties: { label: { type: 'string' }, value: { type: 'string' }, note: { type: 'string' } },
63}
64
65/** `en`: what a model that writes in Chinese hands over so the pane can show English at once. */
66function englishOf(withAdded: boolean) {
67 return {
68 type: 'object',
69 description:
70 'Only when the text you give is not English: its English version, field by field, with no Chinese characters in it. Leave it out when the text is English. Never an English version of text already recorded that you are not changing.',
71 properties: {
72 title: { type: 'string' },
73 summary: { type: 'string' },
74 rationale: { type: 'string' },
75 evidence: { type: 'array', items: EN_EVIDENCE, description: 'Same order as evidence.' },
76 ...(withAdded ? { addEvidence: { type: 'array', items: EN_EVIDENCE, description: 'Same order as addEvidence.' } } : {}),
77 },
78 }
79}
80
81const FIELDS = {
82 title: {
83 type: 'string',
84 description:
85 'A short statement of the decision: one line, about 40 characters at most (the pane shows it on one row); the detail goes in summary and rationale. Technical terms in English; the rest in the language the user writes in.',
86 },
87 status: { enum: [...STATUSES] },
88 kind: { enum: KINDS, default: 'decision' },
89 trunk: { type: 'boolean', description: 'True for a step on the project mainline (listed in the Mainline line at the top of the pane and marked by a thin line beside its row).' },
90 summary: { type: 'string', description: 'The conclusion in one line.' },
91 rationale: { type: 'string', description: 'Why, in a few sentences.' },
92 decidedBy: { enum: DECIDERS, description: '"user" when the user decided, "gate" when a rule agreed beforehand decided, "model" for your own call.' },
93 decidedAt: { type: 'string', description: 'ISO 8601; leave out for now.' },
94}
95
96const RULES = [
97 'RECORD: what the user decides (adopt, reject, withdraw, change a rule, set a next step); conclusions that follow from rules agreed beforehand (a gate letting something through or skipping it: decidedBy "gate"); plans the user accepts (status planned); proposals not decided yet (status proposed).',
98 'DO NOT RECORD: ideas the user has not agreed to (never mark them adopted); progress and logs (the pane\'s "now" block covers those).',
99 'EVIDENCE: key numbers and intervals in evidence[].value and .ci; the run name in evidence[].run; the report path in evidence[].source.',
100 'STATUS FOLLOWS RESULTS: planned -> in-progress -> adopted / rejected. When a new decision replaces an old one, call decision_update on the old node with supersededBy (its status becomes "superseded").',
101 'ONE MATTER, ONE NODE: another seed of the same thing is more evidence (decision_update with addEvidence), not a new node.',
102 'BEFORE RECORDING: pick the parent from the tree in the system prompt; if unsure, call decision_tree first.',
103 'LANGUAGE: write title, summary, rationale, evidence text and notes in the language the user writes in (Chinese if they write Chinese, English if English). Professional names and everyday work words (bug, doc, commit, config, run, seed, metric, ...) always stay in English, also inside Chinese text. When what you write is not English, also pass en: the same fields in English (no Chinese characters in it), so the pane can show English at once; when it is English, leave en out. Never rewrite or translate text already recorded.',
104].join('\n')
105
106export const SPECS: Record<ToolName, ToolSpec> = {
107 decision_log_start: {
108 name: 'decision_log_start',
109 description: [
110 "Start recording this project's decisions as a tree. Data goes to .claude/decisions/ in the project (plain text, meant for git); the tree is shown in a pane beside the transcript.",
111 "Call it only when the user asks to begin, for example 'start recording decisions from now' or '从现在开始记录决定'. Do not call it on your own.",
112 "title: the project's name. rootTitle (optional): the first milestone, recorded as the root of the mainline. statusArgv (optional): only if the user named one, a command as an argv array that prints JSON of live run status for the pane's \"now\" block.",
113 'Once it succeeds, decision_record, decision_update, decision_note, decision_tree, decision_new_tree and decision_use_tree are available from the next user message.',
114 ].join('\n'),
115 inputSchema: {
116 type: 'object',
117 required: ['title'],
118 properties: {
119 title: { type: 'string', description: "The project's name." },
120 rootTitle: { type: 'string', description: 'Optional first milestone, recorded as the root.' },
121 statusArgv: { type: 'array', items: { type: 'string' }, description: 'Optional status command as an argv array.' },
122 },
123 },
124 isDeferred: false,
125 },
126
127 decision_record: {
128 name: 'decision_record',
129 description: [
130 "Record one decision in this project's decision tree. Returns its id and the node it hangs under.",
131 RULES,
132 ].join('\n'),
133 inputSchema: {
134 type: 'object',
135 required: ['title', 'parent', 'status'],
136 properties: {
137 id: { type: 'string', description: 'kebab-case slug; optional (one is made from the title).' },
138 title: FIELDS.title,
139 parent: { type: ['string', 'null'], description: 'Id of the parent node; null for a root.' },
140 status: FIELDS.status,
141 kind: FIELDS.kind,
142 trunk: FIELDS.trunk,
143 summary: FIELDS.summary,
144 rationale: FIELDS.rationale,
145 evidence: { type: 'array', items: EVIDENCE_ITEM },
146 sources: { type: 'array', items: { type: 'string' }, description: 'Report or file paths.' },
147 decidedBy: FIELDS.decidedBy,
148 decidedAt: FIELDS.decidedAt,
149 en: englishOf(false),
150 },
151 },
152 isDeferred: false,
153 },
154
155 decision_update: {
156 name: 'decision_update',
157 description: [
158 'Change a recorded decision: its status, title, summary or rationale; append evidence (addEvidence) or sources (addSources); mark it replaced (supersededBy: the id of the new node; the status becomes "superseded" unless you give one); move it (parent).',
159 'Only the fields you give are changed. summary and rationale accept null to clear. Use it when a result arrives, a plan starts or finishes, or a decision is replaced; not for progress.',
160 RULES,
161 ].join('\n'),
162 inputSchema: {
163 type: 'object',
164 required: ['id'],
165 properties: {
166 id: { type: 'string', description: 'The node to change.' },
167 title: FIELDS.title,
168 parent: { type: ['string', 'null'], description: 'Move the node under this parent; null makes it a root.' },
169 status: FIELDS.status,
170 kind: FIELDS.kind,
171 trunk: FIELDS.trunk,
172 summary: { type: ['string', 'null'] },
173 rationale: { type: ['string', 'null'] },
174 evidence: { type: 'array', items: EVIDENCE_ITEM, description: 'Replaces the whole list; usually use addEvidence.' },
175 sources: { type: 'array', items: { type: 'string' }, description: 'Replaces the whole list; usually use addSources.' },
176 addEvidence: { type: 'array', items: EVIDENCE_ITEM, description: 'Evidence to append (repeats are ignored).' },
177 addSources: { type: 'array', items: { type: 'string' }, description: 'Sources to append.' },
178 supersededBy: { type: ['string', 'null'], description: 'Id of the node that replaces this one.' },
179 decidedBy: FIELDS.decidedBy,
180 decidedAt: FIELDS.decidedAt,
181 en: englishOf(true),
182 },
183 },
184 isDeferred: false,
185 },
186
187 decision_note: {
188 name: 'decision_note',
189 description:
190 'Add a short dated note to a node: a caveat, a follow-up, what the user said about it. Results belong in evidence (decision_update addEvidence), not in notes. Same language rule as decision_record.',
191 inputSchema: {
192 type: 'object',
193 required: ['id', 'text'],
194 properties: {
195 id: { type: 'string' },
196 text: { type: 'string' },
197 en: { type: 'string', description: 'Only when text is not English: its English version, with no Chinese characters in it.' },
198 },
199 },
200 isDeferred: false,
201 },
202
203 decision_tree: {
204 name: 'decision_tree',
205 description: [
206 'Read the decision tree. With no arguments: the mainline plus the 20 most recently touched nodes as compact text (id · status · title · parent).',
207 'With id: that node in full (summary, rationale, evidence, sources, notes, children). With query: nodes whose id or title contains it.',
208 'With trees: true, the trees of this project and where each began; with log: N, the last N events of the current tree, numbered (#N is a version a new tree can start from).',
209 'Call it before recording when you are unsure of the parent or whether the matter is already recorded.',
210 ].join('\n'),
211 inputSchema: {
212 type: 'object',
213 properties: {
214 id: { type: 'string', description: 'A node id (or a piece of its title) to read in full.' },
215 query: { type: 'string', description: 'Find nodes by a piece of their id or title.' },
216 trees: { type: 'boolean', description: 'List the trees of this project.' },
217 log: { type: 'integer', description: 'Show the last N events of the current tree, numbered.' },
218 },
219 },
220 isDeferred: false,
221 },
222
223 decision_new_tree: {
224 name: 'decision_new_tree',
225 description: [
226 'Open a new decision tree in this project: a copy of the current tree (or of one version of it) to record the next line of work in, without touching the original. Recording then switches to the new tree for every session of the project.',
227 "Call it only when the user asks for a new tree, branch or alternative line (for example 'open a new tree from the current one', '新开一个 tree'). Never on your own.",
228 'name: letters, digits and hyphens, 2-32 characters, e.g. "crdt-engine" (main is taken). at (optional): the version to copy: leave out or "current" for everything recorded so far; "#N" for the first N events (decision_tree with log: N numbers them); a date or time such as "2026-10-08 17:44" or "10-08"; or a node id or piece of its title for the tree as it was when that node was recorded. from (optional): the tree to copy, default the current one. empty (optional): true for a blank tree. title (optional): a label for people. stay (optional): true to open it without switching to it.',
229 ].join('\n'),
230 inputSchema: {
231 type: 'object',
232 required: ['name'],
233 properties: {
234 name: { type: 'string', description: 'The new tree\'s name, e.g. "crdt-engine".' },
235 at: { type: 'string', description: 'The version to copy: current, #N, a date or time, or a node.' },
236 from: { type: 'string', description: 'The tree to copy; default the current one.' },
237 empty: { type: 'boolean', description: 'Open a blank tree instead of a copy.' },
238 title: { type: 'string', description: 'A label for the tree.' },
239 stay: { type: 'boolean', description: 'Open it without switching recording to it.' },
240 },
241 },
242 isDeferred: false,
243 },
244
245 decision_use_tree: {
246 name: 'decision_use_tree',
247 description:
248 'Switch recording to another tree of this project ("main" is the first one). Every session of the project follows. Call it only when the user asks to go to another tree; decision_tree with trees: true lists them.',
249 inputSchema: {
250 type: 'object',
251 required: ['name'],
252 properties: {
253 name: { type: 'string', description: 'The tree to record in from now on.' },
254 },
255 },
256 isDeferred: false,
257 },
258}
259
260// ---------------------------------------------------------------- decision_tree output
261
262const RECENT = 20
263const QUERY_LIMIT = 30
264
265function parentLabel(idx: TreeIndex, n: DtNode): string {
266 const p = idx.parentOf.get(n.id) ?? null
267 return p === null ? 'root' : p
268}
269
270function compact(idx: TreeIndex, n: DtNode): string {
271 const flag = n.retracted !== undefined ? ' [retracted]' : ''
272 return `${n.id} · ${n.status} · ${truncate(oneLine(n.title), 80)} · ${parentLabel(idx, n)}${flag}`
273}
274
275function evidenceText(n: DtNode): string[] {
276 return (n.evidence ?? []).map(e => {
277 const parts = [e.label, e.value, e.ci, e.run !== undefined ? `run ${e.run}` : undefined, e.note, e.source !== undefined ? `source ${e.source}` : undefined]
278 return ` - ${parts.filter((p): p is string => p !== undefined && p !== '').join(' | ')}`
279 })
280}
281
282/** One node in full, as plain lines. */
283export function nodeText(idx: TreeIndex, n: DtNode): string {
284 const lines: string[] = [compact(idx, n)]
285 lines.push(`title: ${oneLine(n.title)}`)
286 lines.push(`kind: ${n.kind}${n.trunk === true ? ' · mainline' : ''}`)
287 if (n.decidedBy !== undefined || n.decidedAt !== undefined) lines.push(`decided: ${[n.decidedBy, n.decidedAt].filter(Boolean).join(' ')}`)
288 if (n.summary !== undefined) lines.push(`summary: ${n.summary}`)
289 if (n.rationale !== undefined) lines.push(`rationale: ${n.rationale}`)
290 if ((n.evidence ?? []).length > 0) lines.push('evidence:', ...evidenceText(n))
291 if ((n.sources ?? []).length > 0) lines.push(`sources: ${(n.sources ?? []).join(', ')}`)
292 if (n.supersededBy !== undefined) lines.push(`supersededBy: ${n.supersededBy}`)
293 const replaced = idx.nodes.filter(o => o.supersededBy === n.id).map(o => o.id)
294 if (replaced.length > 0) lines.push(`supersedes: ${replaced.join(', ')}`)
295 const kids = idx.children.get(n.id) ?? []
296 if (kids.length > 0) lines.push('children:', ...kids.slice(0, 30).map(k => ` - ${compact(idx, k)}`))
297 if (n.notes.length > 0) lines.push('notes:', ...n.notes.map(x => ` - ${x.ts.slice(0, 10)} ${x.actor}: ${oneLine(x.text)}`))
298 if (n.retracted !== undefined) lines.push(`retracted by ${n.retracted.actor}${n.retracted.reason !== undefined ? `: ${n.retracted.reason}` : ''}`)
299 return lines.join('\n')
300}
301
302/** The answer to `decision_tree`: the mainline and recent nodes, one node, or a search. */
303export function treeReport(idx: TreeIndex, args: { id?: unknown; query?: unknown }): string {
304 if (idx.nodes.length === 0) return 'The tree is empty. The first decision has parent null.'
305
306 const id = typeof args.id === 'string' ? args.id.trim() : ''
307 if (id !== '') {
308 const r = resolveRef(idx, id)
309 if ('node' in r) return nodeText(idx, r.node)
310 if (r.error === 'ambiguous') return `"${id}" matches several nodes: ${r.ids.join(', ')}. Use an id.`
311 return `No node matches "${id}". Call decision_tree with no arguments to list the tree.`
312 }
313
314 const query = typeof args.query === 'string' ? args.query.trim().toLowerCase() : ''
315 if (query !== '') {
316 const hits = idx.nodes.filter(n => n.id.toLowerCase().includes(query) || n.title.toLowerCase().includes(query))
317 if (hits.length === 0) return `No node has "${query}" in its id or title.`
318 const shown = hits.slice(0, QUERY_LIMIT).map(n => compact(idx, n))
319 return [`${hits.length} match${hits.length === 1 ? '' : 'es'} (id · status · title · parent):`, ...shown, ...(hits.length > QUERY_LIMIT ? [`… and ${hits.length - QUERY_LIMIT} more`] : [])].join('\n')
320 }
321
322 const trunk = trunkNodes(idx)
323 const recent = recentNodes(idx, RECENT)
324 const lines: string[] = []
325 lines.push(
326 trunk.length > 0
327 ? `Mainline: ${trunk.map(n => `${n.id} "${truncate(oneLine(n.title), 40)}"`).join(' > ')}`
328 : 'Mainline: none marked yet (set trunk: true on the steps of the project mainline).',
329 )
330 lines.push(`Recent ${recent.length} of ${idx.nodes.length}, newest first (id · status · title · parent):`)
331 for (const n of recent) lines.push(compact(idx, n))
332 return lines.join('\n')
333}
334lib/view.tsx 361 lines1// What the pane looks like: the layout's blocks turned into elements. All the measuring is in layout.ts;
2// here every block becomes a row of Text and Button, every colour goes through the colour scheme, and
3// every press is handed to the Runtime.
4//
5// Keys: a node's row is a Button keyed by the node's id; every other control has a key starting with
6// `~`, which no id can (ids are lowercase letters, digits and hyphens).
7
8import type { ElementTable, RenderElement, RenderInput } from 'claude-code'
9
10import type { DtStatus } from '../types'
11import type { Action, Part } from './bar'
12import { CONTROL, NEW_KEYS, schemeKey, stepKey, treeKey } from './bar'
13import { STATUSES } from './events'
14import { glyphOf } from './glyphs'
15import { statusLabel, t } from './i18n'
16import type { Dollar } from './io'
17import { layoutPane } from './layout'
18import type { Block } from './layout'
19import { GUTTER, QUIET, S } from './style'
20import { effectiveScheme, paintFor, schemeOf } from './palettes'
21import type { Runtime } from './runtime'
22import { lineText } from './spans'
23import type { Line, Span } from './spans'
24
25export type PaneEvent = RenderInput<'Pane'>
26
27export { CONTROL }
28
29export function isNodeKey(key: string): boolean {
30 return key !== '' && !key.startsWith(CONTROL)
31}
32
33/** Rows a pane docked beside the transcript may use at most; inline it stays small. */
34const INLINE_ROWS = 16
35const MIN_ROWS = 6
36
37type Style = { color?: string; backgroundColor?: string; dimColor?: boolean; bold?: boolean; underline?: boolean; inverse?: boolean }
38
39/** A press handler that never lets a failure escape into the engine. */
40function run(fn: () => Promise<unknown>): () => void {
41 return () => {
42 void fn().catch(() => undefined)
43 }
44}
45
46/**
47 * Draws the pane. `els` is the element table the hook got from `$.ui.resolve(e)` (it cannot be
48 * asked for here: `$` stays in register.tsx); `$` is the Port, whose state reads subscribe the drawing.
49 */
50export async function drawPane($: Dollar, e: PaneEvent, els: ElementTable, rt: Runtime): Promise<RenderElement> {
51 rt.seeViewport(e.viewport)
52 const { Box, Text, Button } = els
53 // The table `$.ui.resolve` hands out is completed to every element name: where a surface draws
54 // no field (the mobile app today) `Input` and `Select` are still there, drawing nothing. So the
55 // surface decides whether the form can be used, and the table only gives the constructors.
56 const hasFields = e.surface !== 'mobile' && 'Input' in els && 'Select' in els
57 const Input = hasFields ? els.Input : null
58 const Select = hasFields ? els.Select : null
59 const canEdit = Input !== null && Select !== null
60
61 const [model, ui, project, now, lang, tm, trees, schemeId, translating] = await Promise.all([
62 $.state.model.read(),
63 $.state.ui.read(),
64 $.state.project.read(),
65 $.state.now.read(),
66 $.state.lang.read(),
67 $.state.tm.read(),
68 $.state.trees.read(),
69 $.state.scheme.read(),
70 $.state.translating.read(),
71 ])
72 const ascii = project.config?.display.asciiGlyphs === true
73 if (project.root === '') rt.repairSoon($) // the session's state was wiped (a /clear): put it back once this drawing is over
74
75 // the accent colours the layout names are the theme's; the scheme in use turns them into its own. While the picker
76 // of colours is open the pane is drawn in the scheme highlighted there: moving the highlight tries a scheme on.
77 const paint = paintFor(effectiveScheme(schemeOf(ui.view === 'colors' && ui.pick !== null ? ui.pick : schemeId), e.surface))
78
79 const bodyRows = e.props.scroll.bodyRows
80 const rows = Math.max(MIN_ROWS, e.props.placement === 'inline' ? Math.min(bodyRows, INLINE_ROWS) : bodyRows)
81 const layout = layoutPane({
82 width: e.props.bodyColumns,
83 rows,
84 lang,
85 ascii,
86 nowMs: rt.nowMs > 0 ? rt.nowMs : Date.now(),
87 project,
88 model,
89 ui,
90 now,
91 tm,
92 trees,
93 translating,
94 statusInterval: project.config?.status?.intervalSec ?? null,
95 canEdit,
96 pin: e.props.placement !== 'inline',
97 scheme: schemeId,
98 surface: e.surface,
99 })
100
101 // ---- pieces of a line
102
103 const styleOf = (s: Span): Style => {
104 const out: Style = {}
105 const color = s.fixed === true ? s.color : paint(s.color)
106 const bg = s.fixed === true ? s.bg : paint(s.bg)
107 if (color !== undefined) out.color = color
108 if (bg !== undefined) out.backgroundColor = bg
109 if (s.dim === true) out.dimColor = true
110 if (s.bold === true) out.bold = true
111 if (s.underline === true) out.underline = true
112 if (s.inverse === true) out.inverse = true
113 return out
114 }
115
116 const piece = (s: Span) => {
117 const style = styleOf(s)
118 return Object.keys(style).length === 0 ? s.text : <Text {...style}>{s.text}</Text>
119 }
120
121 /** The pieces of a line that show something: a span of no columns (the indent of a root) draws nothing. */
122 const pieces = (line: Line) => line.filter(s => s.text !== '').map(piece)
123
124 const lineEl = (line: Line): RenderElement => {
125 const kids = pieces(line)
126 return <Text wrap="truncate-end">{kids.length === 0 ? ' ' : kids}</Text>
127 }
128
129 // ---- what a press asks for
130
131 const actionOf = (a: Action): (() => Promise<unknown>) => {
132 switch (a.do) {
133 case 'move':
134 return () => rt.move($, a.by)
135 case 'filter':
136 return () => rt.setFilter($, a.filter)
137 case 'now':
138 return () => rt.toggleNow($)
139 case 'edit':
140 return () => rt.startEdit($)
141 case 'warnings':
142 return () => rt.showWarnings($)
143 case 'lang':
144 return () => rt.setLang($, a.lang)
145 case 'tab':
146 return () => rt.showTree($, a.tree)
147 case 'cycle':
148 return () => rt.cycleTree($, a.by)
149 case 'overview':
150 return () => rt.openOverview($)
151 case 'back':
152 return () => rt.back($)
153 case 'open':
154 return () => rt.openPicked($)
155 case 'record':
156 return () => rt.recordPicked($)
157 case 'new':
158 return () => rt.openNewTree($)
159 case 'colors':
160 return () => rt.openColors($)
161 case 'use':
162 return () => rt.useHighlighted($)
163 case 'reset':
164 return () => rt.resetColors($)
165 }
166 }
167
168 // ---- a row of buttons and words (the strip, the keys)
169
170 const partEl = (p: Part): RenderElement | null => {
171 if (p.kind === 'text') {
172 const kids = pieces(p.spans)
173 return kids.length === 0 ? null : <Text>{kids}</Text>
174 }
175 const kids = pieces(p.spans)
176 return (
177 <Button key={p.key} {...(p.hotkey !== null ? { hotkey: p.hotkey } : {})} plain {...(p.dim ? { dimColor: true } : {})} onPress={run(actionOf(p.action))}>
178 {kids}
179 </Button>
180 )
181 }
182
183 const barEl = (parts: readonly Part[]): RenderElement => (
184 <Box flexDirection="row">{parts.map(partEl).filter((x): x is RenderElement => x !== null)}</Box>
185 )
186
187 // ---- the edit form
188
189 const editEl = (b: Extract<Block, { kind: 'edit' }>): RenderElement => {
190 const edit = ui.editing
191 if (edit === null || edit.id !== b.id) return lineEl(b.title)
192 const cancel = (
193 <Button key={CONTROL + 'cancel'} onPress={run(() => rt.cancelEdit($))}>
194 {t(lang, 'edit.cancel')}
195 </Button>
196 )
197 if (Input === null || Select === null) {
198 return (
199 <Box flexDirection="column">
200 {lineEl(b.title)}
201 <Box flexDirection="column" paddingLeft={GUTTER}>
202 <Text dimColor wrap="truncate-end">
203 {t(lang, 'edit.mobile')}
204 </Text>
205 {cancel}
206 </Box>
207 </Box>
208 )
209 }
210 const options = STATUSES.map((s: DtStatus) => ({ value: s, label: `${glyphOf(s, ascii)} ${statusLabel(lang, s)}` }))
211 return (
212 <Box flexDirection="column">
213 {lineEl(b.title)}
214 <Box flexDirection="column" paddingLeft={GUTTER}>
215 <Select
216 key={CONTROL + 'status'}
217 label={t(lang, 'edit.status')}
218 options={options}
219 value={edit.status}
220 onSelect={value => {
221 void rt.editStatus($, value).catch(() => undefined)
222 }}
223 />
224 <Input
225 key={CONTROL + 'summary'}
226 label={t(lang, 'edit.summary')}
227 placeholder={t(lang, 'edit.summaryHint')}
228 value={edit.summary}
229 autoFocus
230 onInput={value => {
231 void rt.editSummary($, value).catch(() => undefined)
232 }}
233 onSubmit={value => {
234 void rt.saveEdit($, { summary: value }).catch(() => undefined)
235 }}
236 />
237 <Box flexDirection="row">
238 <Button key={CONTROL + 'save'} variant="primary" onPress={run(() => rt.saveEdit($))}>
239 {t(lang, 'edit.save')}
240 </Button>
241 <Text>{' '}</Text>
242 {cancel}
243 </Box>
244 </Box>
245 </Box>
246 )
247 }
248
249 // ---- the form for a new tree
250
251 const newTreeEl = (b: Extract<Block, { kind: 'newtree' }>): RenderElement => {
252 const form = ui.newTree
253 if (form === null) return lineEl(b.title)
254 const cancel = (
255 <Button key={NEW_KEYS.cancel} onPress={run(() => rt.cancelNewTree($))}>
256 {t(lang, 'edit.cancel')}
257 </Button>
258 )
259 if (Input === null) {
260 return (
261 <Box flexDirection="column">
262 {lineEl(b.title)}
263 <Box flexDirection="column" paddingLeft={GUTTER}>
264 <Text dimColor wrap="truncate-end">
265 {t(lang, 'trees.new.mobile')}
266 </Text>
267 {cancel}
268 </Box>
269 </Box>
270 )
271 }
272 const field = (key: string, name: 'name' | 'at' | 'title', label: string, hint: string, autoFocus: boolean) => (
273 <Input
274 key={key}
275 label={label}
276 placeholder={hint}
277 value={form[name]}
278 {...(autoFocus ? { autoFocus: true as const } : {})}
279 onInput={value => {
280 void rt.newTreeField($, name, value).catch(() => undefined)
281 }}
282 onSubmit={value => {
283 void rt.createTree($, { [name]: value }).catch(() => undefined)
284 }}
285 />
286 )
287 // what went wrong, else what the tree will be
288 const note: Line = form.error !== null ? [S(form.error, { color: 'error' })] : [S(t(lang, 'trees.new.recording'), QUIET)]
289 return (
290 <Box flexDirection="column">
291 {lineEl(b.title)}
292 <Box flexDirection="column" paddingLeft={GUTTER}>
293 {field(NEW_KEYS.name, 'name', t(lang, 'trees.new.name'), t(lang, 'trees.new.nameHint'), true)}
294 {field(NEW_KEYS.at, 'at', t(lang, 'trees.new.at'), t(lang, 'trees.new.atHint'), false)}
295 {field(NEW_KEYS.title, 'title', t(lang, 'trees.new.label'), t(lang, 'trees.new.labelHint'), false)}
296 {lineEl(note)}
297 <Box flexDirection="row">
298 <Button key={NEW_KEYS.create} variant="primary" onPress={run(() => rt.createTree($))}>
299 {t(lang, 'trees.new.create')}
300 </Button>
301 <Text>{' '}</Text>
302 {cancel}
303 </Box>
304 </Box>
305 </Box>
306 )
307 }
308
309 // ---- one block
310
311 const blockEl = (b: Block): RenderElement => {
312 switch (b.kind) {
313 case 'text':
314 case 'rule':
315 return lineEl(b.line)
316 case 'row':
317 return (
318 <Button key={b.id} label={lineText(b.line).trim()} plain onPress={run(() => rt.press($, b.id))}>
319 {pieces(b.line)}
320 </Button>
321 )
322 case 'page':
323 return (
324 <Button key={CONTROL + 'page-' + b.dir} plain dimColor onPress={run(() => rt.move($, b.dir === 'up' ? -b.step : b.step))}>
325 {lineText(b.line)}
326 </Button>
327 )
328 case 'bar':
329 return barEl(b.parts)
330 case 'allow':
331 return (
332 <Box flexDirection="row">
333 <Text>{pieces(b.left)}</Text>
334 <Button key={CONTROL + 'allow'} variant="primary" onPress={run(() => rt.approveStatus($, b.argv))}>
335 {b.label}
336 </Button>
337 <Text>{pieces(b.right)}</Text>
338 </Box>
339 )
340 case 'edit':
341 return editEl(b)
342 case 'tree':
343 return (
344 <Button key={b.step !== null ? stepKey(b.tree, b.step) : treeKey(b.tree, b.part)} label={b.label} plain onPress={run(() => rt.pressTree($, b.tree, b.step))}>
345 {pieces(b.line)}
346 </Button>
347 )
348 case 'newtree':
349 return newTreeEl(b)
350 case 'scheme':
351 return (
352 <Button key={schemeKey(b.id)} label={b.label} plain onPress={run(() => rt.pressScheme($, b.id))}>
353 {pieces(b.line)}
354 </Button>
355 )
356 }
357 }
358
359 return <Box flexDirection="column">{layout.blocks.map(blockEl)}</Box>
360}
361lib/bar.ts 126 lines1// A row of the pane that holds buttons: the strip at the top (the tabs, the language switch) and the keys
2// at the bottom. The layout decides what is on a row and how wide it is; the view turns each part into
3// a `Text` or a `Button`, and a press becomes an `Action` for the Runtime.
4
5import type { DtFilter, DtLang } from '../types'
6import type { Line } from './spans'
7import { lineWidth } from './spans'
8import { strWidth } from './width'
9
10/** Control keys start with this; a node's key is its id, which no id can start with (ids are lowercase letters, digits and hyphens). */
11export const CONTROL = '~'
12
13/** A line of the list of trees in the overview is a button with this key and the tree's name (`~tree:cloud-first`; a second line adds `:2`). */
14export const TREE_KEY = `${CONTROL}tree:`
15/** A step of the overview's graph: this key, the tree's name, a colon and the step's id (`~step:cloud-first:c2`). */
16export const STEP_KEY = `${CONTROL}step:`
17
18/** A line of the picker of colours is a button with this key and the id of the scheme (`~scheme:nord`). */
19export const SCHEME_KEY = `${CONTROL}scheme:`
20
21export function schemeKey(id: string): string {
22 return `${SCHEME_KEY}${id}`
23}
24
25/** The scheme a control of the picker stands for; null for any other key. */
26export function schemeOfKey(key: string): string | null {
27 return key.startsWith(SCHEME_KEY) && key.length > SCHEME_KEY.length ? key.slice(SCHEME_KEY.length) : null
28}
29
30/** The controls of the form for a new tree. */
31export const NEW_KEYS = {
32 name: `${CONTROL}new-name`,
33 at: `${CONTROL}new-at`,
34 title: `${CONTROL}new-title`,
35 create: `${CONTROL}new-create`,
36 cancel: `${CONTROL}new-cancel`,
37} as const
38
39export function treeKey(tree: string, part: 0 | 1 = 0): string {
40 return part === 0 ? `${TREE_KEY}${tree}` : `${TREE_KEY}${tree}:2`
41}
42
43export function stepKey(tree: string, id: string): string {
44 return `${STEP_KEY}${tree}:${id}`
45}
46
47/** The tree (and the step, for a point of the graph) a control of the overview stands for; null for any other key. */
48export function treeOfKey(key: string): { tree: string; step: string | null } | null {
49 if (key.startsWith(TREE_KEY)) {
50 const rest = key.slice(TREE_KEY.length).replace(/:2$/, '')
51 return rest === '' ? null : { tree: rest, step: null }
52 }
53 if (key.startsWith(STEP_KEY)) {
54 const rest = key.slice(STEP_KEY.length)
55 const at = rest.indexOf(':')
56 // (a tree's name has no colon in it, nor has a step's id)
57 return at <= 0 || at === rest.length - 1 ? null : { tree: rest.slice(0, at), step: rest.slice(at + 1) }
58 }
59 return null
60}
61
62/** What a press on a control asks of the Runtime. */
63export type Action =
64 | { do: 'move'; by: number }
65 | { do: 'filter'; filter: Exclude<DtFilter, 'default'> }
66 | { do: 'now' }
67 | { do: 'edit' }
68 | { do: 'warnings' }
69 | { do: 'lang'; lang: DtLang }
70 /** A tab of the strip: look at that tree. */
71 | { do: 'tab'; tree: string }
72 /** The next or the previous tree. */
73 | { do: 'cycle'; by: 1 | -1 }
74 /** The overview of the trees, and back from it (or from the picker). */
75 | { do: 'overview' }
76 | { do: 'back' }
77 /** The colour picker. */
78 | { do: 'colors' }
79 /** In the overview: look at the highlighted tree, record into it, open the form for a new one. */
80 | { do: 'open' }
81 | { do: 'record' }
82 | { do: 'new' }
83 /** In the picker: use the highlighted scheme, or go back to the default one. */
84 | { do: 'use' }
85 | { do: 'reset' }
86
87export type Part =
88 | { kind: 'text'; spans: Line }
89 | {
90 kind: 'button'
91 /** The control's address; it starts with `~` so that no node's id can be it. */
92 key: string
93 /** The key that presses it while the pane has the focus (one digit or lowercase letter). */
94 hotkey: string | null
95 action: Action
96 /** What is drawn on it. With a hotkey the engine draws `key: ` before it. */
97 spans: Line
98 /** Drawn quiet until the pointer or the focus is on it. */
99 dim: boolean
100 }
101
102export function textPart(spans: Line): Part {
103 return { kind: 'text', spans }
104}
105
106export function buttonPart(key: string, action: Action, spans: Line, o: { hotkey?: string; dim?: boolean } = {}): Part {
107 return { kind: 'button', key, hotkey: o.hotkey ?? null, action, spans, dim: o.dim === true }
108}
109
110/** The columns a part takes: a plain button with a hotkey is drawn `key: label`. */
111export function partWidth(p: Part): number {
112 if (p.kind === 'text') return lineWidth(p.spans)
113 return (p.hotkey === null ? 0 : strWidth(p.hotkey) + 2) + lineWidth(p.spans)
114}
115
116export function partsWidth(parts: readonly Part[]): number {
117 return parts.reduce((sum, p) => sum + partWidth(p), 0)
118}
119
120/** The words a part says, for tests and for the name of a control: its label, hotkey first. */
121export function partText(p: Part): string {
122 const label = p.spans.map(s => s.text).join('')
123 if (p.kind === 'text') return label
124 return p.hotkey === null ? label : `${p.hotkey}: ${label}`
125}
126lib/config.ts 125 lines1// `.claude/decisions/config.json`: the project's own settings, shared through git.
2
3import type { DtConfig, DtLanguage, DtStatusConfig } from '../types'
4import { MAIN, isTreeName } from './events'
5
6export const DEFAULT_INTERVAL_SEC = 30
7export const DEFAULT_TIMEOUT_SEC = 15
8
9export function defaultConfig(title: string, startedAt: string): DtConfig {
10 return {
11 v: 1,
12 title,
13 startedAt,
14 recording: true,
15 display: { asciiGlyphs: false, autoOpen: true, language: 'auto', translate: true },
16 }
17}
18
19export function withStatus(config: DtConfig, argv: readonly string[]): DtConfig {
20 return {
21 ...config,
22 status: { argv: [...argv], intervalSec: DEFAULT_INTERVAL_SEC, timeoutSec: DEFAULT_TIMEOUT_SEC },
23 }
24}
25
26export function serializeConfig(config: DtConfig): string {
27 return `${JSON.stringify(config, null, 2)}\n`
28}
29
30function clamp(n: unknown, lo: number, hi: number, fallback: number): number {
31 return typeof n === 'number' && Number.isFinite(n) ? Math.min(hi, Math.max(lo, Math.round(n))) : fallback
32}
33
34function isRecord(x: unknown): x is Record<string, unknown> {
35 return x !== null && typeof x === 'object' && !Array.isArray(x)
36}
37
38/** Reads config.json's text. Fields that are missing or the wrong type take their defaults. */
39export function parseConfig(text: string): { config: DtConfig } | { error: string } {
40 let raw: unknown
41 try {
42 raw = JSON.parse(text)
43 } catch {
44 return { error: 'config.json is not valid JSON' }
45 }
46 if (!isRecord(raw)) return { error: 'config.json is not a JSON object' }
47 if (raw.v !== undefined && raw.v !== 1) return { error: `config.json has an unsupported version ${JSON.stringify(raw.v)}` }
48
49 let status: DtStatusConfig | undefined
50 if (raw.status !== undefined && raw.status !== null) {
51 const s = raw.status
52 if (!isRecord(s) || !Array.isArray(s.argv) || s.argv.length === 0 || !s.argv.every(a => typeof a === 'string' && a !== '')) {
53 return { error: 'config.json: status.argv must be a non-empty list of strings' }
54 }
55 status = {
56 argv: s.argv as string[],
57 intervalSec: clamp(s.intervalSec, 5, 3600, DEFAULT_INTERVAL_SEC),
58 timeoutSec: clamp(s.timeoutSec, 1, 120, DEFAULT_TIMEOUT_SEC),
59 }
60 }
61
62 const d = isRecord(raw.display) ? raw.display : {}
63 const language: DtLanguage = d.language === 'zh' || d.language === 'en' ? d.language : 'auto'
64 const config: DtConfig = {
65 v: 1,
66 title: typeof raw.title === 'string' && raw.title.trim() !== '' ? raw.title.trim() : 'project',
67 startedAt: typeof raw.startedAt === 'string' ? raw.startedAt : '',
68 recording: raw.recording !== false,
69 // a name that cannot be a tree is not an error: the project stays on main
70 ...(isTreeName(raw.tree) && raw.tree !== MAIN ? { tree: raw.tree } : {}),
71 ...(status !== undefined ? { status } : {}),
72 display: {
73 asciiGlyphs: d.asciiGlyphs === true,
74 autoOpen: d.autoOpen !== false,
75 language,
76 translate: d.translate !== false,
77 },
78 }
79 return { config }
80}
81
82/**
83 * Sets `recording` in config.json's text and leaves every other key (including
84 * ones a newer version added) as it was. Null when the text is not an object.
85 */
86export function setRecording(text: string, recording: boolean): string | null {
87 let raw: unknown
88 try {
89 raw = JSON.parse(text)
90 } catch {
91 return null
92 }
93 if (!isRecord(raw)) return null
94 return `${JSON.stringify({ ...raw, recording }, null, 2)}\n`
95}
96
97/** Changes keys of `display` in config.json's text and leaves everything else as it was. Null when the text is not an object. */
98export function setDisplay(text: string, patch: Partial<DtConfig['display']>): string | null {
99 let raw: unknown
100 try {
101 raw = JSON.parse(text)
102 } catch {
103 return null
104 }
105 if (!isRecord(raw)) return null
106 const display = isRecord(raw.display) ? raw.display : {}
107 return `${JSON.stringify({ ...raw, display: { ...display, ...patch } }, null, 2)}\n`
108}
109
110/**
111 * Points config.json at a tree (`main` removes the key) and leaves every other key as it was.
112 * Null when the text is not an object.
113 */
114export function setTree(text: string, tree: string): string | null {
115 let raw: unknown
116 try {
117 raw = JSON.parse(text)
118 } catch {
119 return null
120 }
121 if (!isRecord(raw)) return null
122 const { tree: _old, ...rest } = raw
123 return `${JSON.stringify(tree === MAIN ? rest : { ...rest, tree }, null, 2)}\n`
124}
125lib/commands.ts 295 lines1// Parsing of `/decide ...`: pure text in, a plain description of what was asked out.
2// Running it (writing events, opening the pane) is the runtime's job.
3
4import type { DtKind, DtStatus } from '../types'
5import { isKind, isStatus } from './events'
6
7export type Parsed =
8 | { cmd: 'edit' }
9 | { cmd: 'start'; title: string }
10 | { cmd: 'init'; title: string }
11 | { cmd: 'pause' }
12 | { cmd: 'resume' }
13 | { cmd: 'status'; ref: string; status: DtStatus }
14 | { cmd: 'note'; ref: string; text: string }
15 | { cmd: 'add'; title: string; parent: string | null; status: DtStatus; kind: DtKind; id: string | null; trunk: boolean }
16 | { cmd: 'retract'; ref: string; reason: string }
17 | { cmd: 'import'; file: string }
18 | { cmd: 'lang'; value: 'zh' | 'en' | 'auto' }
19 | { cmd: 'autoopen'; value: boolean }
20 | { cmd: 'translate'; value: boolean }
21 /** The colour scheme: `name` null asks which one is in use. */
22 | { cmd: 'colors'; name: string | null }
23 | { cmd: 'colors-reset' }
24 | { cmd: 'tree-list' }
25 | { cmd: 'tree-new'; name: string; from: string | null; at: string | null; empty: boolean; title: string | null; stay: boolean }
26 | { cmd: 'tree-use'; name: string }
27 | { cmd: 'tree-log'; limit: number | null }
28 | { cmd: 'usage' }
29 | { cmd: 'error'; what: 'missing'; arg: 'status' | 'node' | 'text' | 'title' | 'file' | 'value' | 'name' }
30 | { cmd: 'error'; what: 'status'; value: string }
31
32const QUOTES: Record<string, string> = { '"': '"', "'": "'", '“': '”', '‘': '’', '「': '」' }
33
34/** Strips one pair of matching quotes around `s`. */
35export function unquote(s: string): string {
36 const t = s.trim()
37 if (t.length >= 2) {
38 const close = QUOTES[t[0]!]
39 if (close !== undefined && t[t.length - 1] === close) return t.slice(1, -1).trim()
40 }
41 return t
42}
43
44/** The first word of `s` (a quoted phrase counts as one word) and what follows it. */
45export function firstWord(s: string): { word: string; rest: string } {
46 const t = s.trimStart()
47 if (t === '') return { word: '', rest: '' }
48 const close = QUOTES[t[0]!]
49 if (close !== undefined) {
50 const end = t.indexOf(close, 1)
51 if (end > 0) return { word: t.slice(1, end).trim(), rest: t.slice(end + 1).trim() }
52 }
53 const m = /^(\S+)\s*([\s\S]*)$/.exec(t)
54 return { word: m?.[1] ?? '', rest: (m?.[2] ?? '').trim() }
55}
56
57const STATUS_WORDS: Record<string, DtStatus> = {
58 采用: 'adopted',
59 通过: 'adopted',
60 否决: 'rejected',
61 拒绝: 'rejected',
62 无差别: 'no-diff',
63 进行中: 'in-progress',
64 计划: 'planned',
65 计划中: 'planned',
66 提议: 'proposed',
67 撤回: 'withdrawn',
68 被取代: 'superseded',
69 取代: 'superseded',
70 暂缓: 'deferred',
71}
72
73/** A status by id, English label or Chinese label. */
74export function parseStatus(raw: string): DtStatus | null {
75 const s = unquote(raw)
76 if (s === '') return null
77 const key = s.toLowerCase().replace(/[\s_]+/g, '-')
78 if (isStatus(key)) return key
79 if (key === 'nodiff') return 'no-diff'
80 if (key === 'inprogress' || key === 'doing' || key === 'wip') return 'in-progress'
81 if (key === 'accepted' || key === 'accept' || key === 'done') return 'adopted'
82 // (own keys only: "constructor" or "toString" are not statuses)
83 return Object.hasOwn(STATUS_WORDS, s) ? STATUS_WORDS[s]! : null
84}
85
86type FlagKinds = {
87 /** Flags whose value runs to the next flag, spaces and all (`--at 2026-10-08 17:44`). */
88 multi?: readonly string[]
89 /** Flags that take no value (`--empty`). */
90 bool?: readonly string[]
91}
92
93/** Splits `--flag value` pairs off the end of a line. A flag takes one word unless `kinds` says otherwise. */
94function takeFlags(text: string, kinds: FlagKinds = {}): { head: string; flags: Map<string, string | true> } {
95 const flags = new Map<string, string | true>()
96 const at = text.search(/(^|\s)--[a-z]/i)
97 if (at < 0) return { head: text.trim(), flags }
98 const head = text.slice(0, at).trim()
99 let rest = text.slice(at).trim()
100 while (rest !== '') {
101 const m = /^--([a-z][a-z-]*)\s*([\s\S]*)$/i.exec(rest)
102 if (m === null) break
103 const name = m[1]!.toLowerCase()
104 const after = m[2]!
105 if (kinds.bool?.includes(name) === true || after === '' || after.startsWith('--')) {
106 flags.set(name, true)
107 rest = after.trim()
108 continue
109 }
110 if (kinds.multi?.includes(name) === true) {
111 const cut = after.search(/\s--[a-z]/i)
112 flags.set(name, unquote(cut < 0 ? after : after.slice(0, cut)))
113 rest = cut < 0 ? '' : after.slice(cut).trim()
114 continue
115 }
116 const { word, rest: remaining } = firstWord(after)
117 flags.set(name, word)
118 rest = remaining
119 }
120 return { head, flags }
121}
122
123const text = (v: string | true | undefined): string | null => (typeof v === 'string' && v !== '' ? v : null)
124
125function parseTreeNew(rest: string): Parsed {
126 const { head, flags } = takeFlags(rest, { multi: ['at', 'version', 'from', 'title'], bool: ['empty', 'stay'] })
127 const name = unquote(head)
128 if (name === '') return { cmd: 'error', what: 'missing', arg: 'name' }
129 return {
130 cmd: 'tree-new',
131 name,
132 from: text(flags.get('from')),
133 at: text(flags.get('at')) ?? text(flags.get('version')),
134 empty: flags.get('empty') === true,
135 title: text(flags.get('title')),
136 stay: flags.get('stay') === true,
137 }
138}
139
140function parseTree(rest: string): Parsed {
141 const { word, rest: more } = firstWord(rest)
142 switch (word.toLowerCase()) {
143 case '':
144 case 'list':
145 case 'ls':
146 return { cmd: 'tree-list' }
147 case 'new':
148 case 'create':
149 case 'fork':
150 case 'open':
151 case '新建':
152 case '新开':
153 return parseTreeNew(more)
154 case 'use':
155 case 'switch':
156 case 'checkout':
157 case '切换':
158 return more === '' ? { cmd: 'error', what: 'missing', arg: 'name' } : { cmd: 'tree-use', name: unquote(more) }
159 case 'log':
160 case 'history':
161 case '历史':
162 return { cmd: 'tree-log', limit: /^\d+$/.test(more) ? Math.max(1, Number(more)) : null }
163 default:
164 return { cmd: 'usage' }
165 }
166}
167
168/** Parses what follows `/decide`. */
169export function parseDecide(args: string): Parsed {
170 const text = args.trim()
171 if (text === '') return { cmd: 'edit' }
172 const { word, rest } = firstWord(text)
173 const sub = word.toLowerCase()
174
175 switch (sub) {
176 case 'help':
177 case '?':
178 return { cmd: 'usage' }
179
180 case 'start':
181 return { cmd: 'start', title: unquote(rest) }
182
183 case 'init':
184 case 'initialize':
185 case '初始化':
186 return { cmd: 'init', title: unquote(rest) }
187
188 case 'pause':
189 return { cmd: 'pause' }
190
191 case 'resume':
192 return { cmd: 'resume' }
193
194 case 'status': {
195 if (rest === '') return { cmd: 'error', what: 'missing', arg: 'node' }
196 // the status is at the end, and whatever precedes it names the node; two words first ("In progress", "No diff")
197 const two = /^([\s\S]*?\S)\s+(\S+\s+\S+)$/.exec(rest)
198 if (two !== null) {
199 const status = parseStatus(two[2]!)
200 if (status !== null) return { cmd: 'status', ref: unquote(two[1]!), status }
201 }
202 const m = /^([\s\S]*?)\s+(\S+)$/.exec(rest)
203 if (m === null) return { cmd: 'error', what: 'missing', arg: 'status' }
204 const status = parseStatus(m[2]!)
205 if (status === null) return { cmd: 'error', what: 'status', value: m[2]! }
206 return { cmd: 'status', ref: unquote(m[1]!), status }
207 }
208
209 case 'note': {
210 const { word: ref, rest: note } = firstWord(rest)
211 if (ref === '') return { cmd: 'error', what: 'missing', arg: 'node' }
212 if (note === '') return { cmd: 'error', what: 'missing', arg: 'text' }
213 return { cmd: 'note', ref, text: note }
214 }
215
216 case 'add': {
217 const { head, flags } = takeFlags(rest)
218 const title = unquote(head)
219 if (title === '') return { cmd: 'error', what: 'missing', arg: 'title' }
220 const statusRaw = flags.get('status')
221 let status: DtStatus = 'planned'
222 if (typeof statusRaw === 'string') {
223 const parsed = parseStatus(statusRaw)
224 if (parsed === null) return { cmd: 'error', what: 'status', value: statusRaw }
225 status = parsed
226 }
227 const kindRaw = flags.get('kind')
228 const kind: DtKind = typeof kindRaw === 'string' && isKind(kindRaw) ? kindRaw : 'decision'
229 const parentRaw = flags.get('parent')
230 const parent = typeof parentRaw === 'string' && parentRaw !== '' && parentRaw !== 'null' && parentRaw !== 'root' ? parentRaw : null
231 const idRaw = flags.get('id')
232 return { cmd: 'add', title, parent, status, kind, id: typeof idRaw === 'string' ? idRaw : null, trunk: flags.get('trunk') === true }
233 }
234
235 case 'retract': {
236 const { word: ref, rest: reason } = firstWord(rest)
237 if (ref === '') return { cmd: 'error', what: 'missing', arg: 'node' }
238 return { cmd: 'retract', ref, reason }
239 }
240
241 case 'import':
242 return rest === '' ? { cmd: 'error', what: 'missing', arg: 'file' } : { cmd: 'import', file: unquote(rest) }
243
244 case 'lang': {
245 const v = rest.toLowerCase()
246 if (v === 'zh' || v === 'en' || v === 'auto') return { cmd: 'lang', value: v }
247 return { cmd: 'error', what: 'missing', arg: 'value' }
248 }
249
250 case 'tree':
251 case 'trees':
252 return parseTree(rest)
253
254 // the short forms
255 case 'new':
256 return parseTreeNew(rest)
257
258 case 'use':
259 case 'switch':
260 return rest === '' ? { cmd: 'error', what: 'missing', arg: 'name' } : { cmd: 'tree-use', name: unquote(rest) }
261
262 case 'log':
263 return { cmd: 'tree-log', limit: /^\d+$/.test(rest) ? Math.max(1, Number(rest)) : null }
264
265 case 'translate':
266 case 'translation': {
267 const v = rest.toLowerCase()
268 if (v === 'on' || v === 'true' || v === 'yes') return { cmd: 'translate', value: true }
269 if (v === 'off' || v === 'false' || v === 'no') return { cmd: 'translate', value: false }
270 return { cmd: 'error', what: 'missing', arg: 'value' }
271 }
272
273 case 'colors':
274 case 'colours':
275 case 'color':
276 case 'colour':
277 case '配色': {
278 const name = unquote(rest)
279 if (name === '') return { cmd: 'colors', name: null }
280 if (/^(reset|default)$/i.test(name) || name === '默认' || name === '重置') return { cmd: 'colors-reset' }
281 return { cmd: 'colors', name }
282 }
283
284 case 'autoopen': {
285 const v = rest.toLowerCase()
286 if (v === 'on' || v === 'true' || v === 'yes') return { cmd: 'autoopen', value: true }
287 if (v === 'off' || v === 'false' || v === 'no') return { cmd: 'autoopen', value: false }
288 return { cmd: 'error', what: 'missing', arg: 'value' }
289 }
290
291 default:
292 return { cmd: 'usage' }
293 }
294}
295lib/events.ts 242 lines1// Event files: names, time formats, parsing and the small vocabularies the
2// rest of the mod shares. Nothing here touches the file system.
3
4import type { DecisionEvent, DtActor, DtDecidedBy, DtKind, DtStatus } from '../types'
5import { isHidden } from './width'
6
7export const STATUSES: readonly DtStatus[] = [
8 'adopted',
9 'rejected',
10 'no-diff',
11 'in-progress',
12 'planned',
13 'proposed',
14 'withdrawn',
15 'superseded',
16 'deferred',
17]
18export const KINDS: readonly DtKind[] = ['decision', 'protocol', 'design', 'milestone']
19export const ACTORS: readonly DtActor[] = ['user', 'model', 'gate', 'import']
20export const DECIDERS: readonly DtDecidedBy[] = ['user', 'gate', 'model']
21export const OPS = ['start', 'add', 'update', 'note', 'move', 'retract', 'fork'] as const
22
23/** Statuses that mean "not decided yet": what the `p` filter shows. */
24export const PENDING_STATUSES: readonly DtStatus[] = ['planned', 'proposed', 'in-progress']
25
26export function isStatus(x: unknown): x is DtStatus {
27 return typeof x === 'string' && (STATUSES as readonly string[]).includes(x)
28}
29export function isKind(x: unknown): x is DtKind {
30 return typeof x === 'string' && (KINDS as readonly string[]).includes(x)
31}
32export function isActor(x: unknown): x is DtActor {
33 return typeof x === 'string' && (ACTORS as readonly string[]).includes(x)
34}
35export function isDecider(x: unknown): x is DtDecidedBy {
36 return typeof x === 'string' && (DECIDERS as readonly string[]).includes(x)
37}
38
39// ---------------------------------------------------------------- time
40
41const p2 = (n: number) => String(n).padStart(2, '0')
42const p3 = (n: number) => String(n).padStart(3, '0')
43
44/** `YYYYMMDDTHHMMSSmmmZ` (UTC): the time part of an event file's name. */
45export function utcStamp(ms: number): string {
46 const d = new Date(ms)
47 return (
48 `${d.getUTCFullYear()}${p2(d.getUTCMonth() + 1)}${p2(d.getUTCDate())}` +
49 `T${p2(d.getUTCHours())}${p2(d.getUTCMinutes())}${p2(d.getUTCSeconds())}${p3(d.getUTCMilliseconds())}Z`
50 )
51}
52
53/** Epoch ms of the UTC stamp at the front of an event file's name (`YYYYMMDDTHHMMSS[mmm]Z`), or null. */
54export function stampMs(name: string): number | null {
55 const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})?Z/.exec(name)
56 if (m === null) return null
57 return Date.UTC(+m[1]!, +m[2]! - 1, +m[3]!, +m[4]!, +m[5]!, +m[6]!, m[7] === undefined ? 0 : +m[7]!)
58}
59
60/** ISO 8601 with the local UTC offset, e.g. `2026-10-10T00:28:00.123-04:00`. */
61export function isoLocal(ms: number): string {
62 const d = new Date(ms)
63 const off = -d.getTimezoneOffset()
64 const sign = off >= 0 ? '+' : '-'
65 const abs = Math.abs(off)
66 return (
67 `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}` +
68 `T${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}.${p3(d.getMilliseconds())}` +
69 `${sign}${p2(Math.floor(abs / 60))}:${p2(abs % 60)}`
70 )
71}
72
73/** Epoch ms of an ISO-ish string, or null when it does not parse. */
74export function parseTime(iso: string | null | undefined): number | null {
75 if (typeof iso !== 'string' || iso === '') return null
76 const ms = Date.parse(iso)
77 return Number.isNaN(ms) ? null : ms
78}
79
80/** Whether `s` is a date or date-time `Date.parse` understands and looks like ISO 8601. */
81export function isIso(s: string): boolean {
82 return /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}:?\d{2})?$/.test(s) && parseTime(s) !== null
83}
84
85/** `MM-DD` in the viewer's local time; empty when `iso` does not parse. */
86export function fmtDate(iso: string | null | undefined): string {
87 // a bare date has no time to convert: Date.parse reads it as UTC midnight, which
88 // would show the day before to anyone west of Greenwich
89 if (typeof iso === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(iso)) return iso.slice(5, 10)
90 const ms = parseTime(iso)
91 if (ms === null) return typeof iso === 'string' && /^\d{4}-\d{2}-\d{2}/.test(iso) ? iso.slice(5, 10) : ''
92 const d = new Date(ms)
93 return `${p2(d.getMonth() + 1)}-${p2(d.getDate())}`
94}
95
96/** `HH:MM` in the viewer's local time; empty when `iso` does not parse. */
97export function fmtClock(iso: string | null | undefined): string {
98 const ms = parseTime(iso)
99 if (ms === null) return ''
100 const d = new Date(ms)
101 return `${p2(d.getHours())}:${p2(d.getMinutes())}`
102}
103
104/** `HH:MM` for a moment of today, `MM-DD` for any other day (a bare date has no clock to show), in the viewer's local time; empty when `iso` does not parse. */
105export function fmtWhen(iso: string | null | undefined, nowMs: number): string {
106 if (typeof iso === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(iso)) return fmtDate(iso)
107 const ms = parseTime(iso)
108 if (ms === null) return fmtDate(iso)
109 const a = new Date(ms)
110 const b = new Date(nowMs)
111 const today = a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
112 return today ? fmtClock(iso) : fmtDate(iso)
113}
114
115// ---------------------------------------------------------------- names
116
117/** `events/<ts>_<session>_<seq>.json`: file-name order is event order. */
118export function eventFileName(ms: number, session: string, seq: number): string {
119 return `${utcStamp(ms)}_${session}_${String(seq).padStart(4, '0')}.json`
120}
121
122/** A file in events/ that is read as an event: any `.json` that is not hidden. */
123export function isEventFile(name: string): boolean {
124 return name.endsWith('.json') && !name.startsWith('.')
125}
126
127/** The 6-character session id: the host's session id cut down, else random. */
128export function shortSession(raw: string | null | undefined, random: () => number = Math.random): string {
129 const cleaned = (raw ?? '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 6)
130 if (cleaned.length === 6) return cleaned
131 let out = cleaned
132 while (out.length < 6) out += Math.floor(random() * 36).toString(36)
133 return out
134}
135
136// ---------------------------------------------------------------- trees
137
138/** The tree every project has: `.claude/decisions/events/`. The others live in `.claude/decisions/trees/<name>/`. */
139export const MAIN = 'main'
140
141/** The name of a tree other than main: lower-case letters, digits and hyphens, 2 to 32 characters. */
142export const TREE_NAME = /^[a-z0-9][a-z0-9-]{1,31}$/
143
144/** A tree name as it appears in files (main, or a valid name). */
145export function isTreeName(x: unknown): x is string {
146 return typeof x === 'string' && (x === MAIN || TREE_NAME.test(x))
147}
148
149/**
150 * A tree name from what the person typed: used as it is when it is one, else made into one
151 * (`Side B` -> `side-b`) when only letters, digits and spaces or punctuation have to change. Text
152 * with other scripts in it is not turned into something else behind the person's back.
153 */
154export function treeNameOf(raw: string): { name: string } | { error: 'empty' | 'main' | 'invalid' } {
155 const text = raw.trim()
156 if (text === '') return { error: 'empty' }
157 const ascii = /^[\x20-\x7e]+$/.test(text)
158 const name = TREE_NAME.test(text) ? text : ascii ? text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '') : ''
159 if (name === MAIN) return { error: 'main' }
160 return TREE_NAME.test(name) ? { name } : { error: 'invalid' }
161}
162
163// ---------------------------------------------------------------- ids
164
165export const ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,47}$/
166
167/** The longest id an event file may bring (the ones this mod makes are at most 48). */
168export const MAX_ID = 64
169
170/**
171 * Whether an id from an event file can be used. Scripts and other tools write ids too, so this is
172 * wider than the pattern of the ids made here (underscores, capitals, dots are fine); what it keeps
173 * out is what would break the places an id is shown, matched and keyed: spaces, control and
174 * invisible characters, a leading `~` (the pane's own keys start with it) and anything over MAX_ID.
175 */
176export function isNodeId(x: unknown): x is string {
177 if (typeof x !== 'string' || x.length === 0 || x.length > MAX_ID || x.startsWith('~')) return false
178 for (const ch of x) {
179 if (ch === ' ' || isHidden(ch.codePointAt(0)!)) return false
180 }
181 return true
182}
183
184/** A kebab-case slug from free text; empty when nothing usable is left (e.g. all CJK). */
185export function slugify(raw: string): string {
186 const slug = raw
187 .toLowerCase()
188 .replace(/[^a-z0-9]+/g, '-')
189 .replace(/^-+|-+$/g, '')
190 .slice(0, 48)
191 .replace(/-+$/g, '')
192 return ID_PATTERN.test(slug) ? slug : ''
193}
194
195/** `d-<base36 time>`: the id of a node nobody gave a slug. */
196export function autoId(ms: number): string {
197 return `d-${ms.toString(36)}`
198}
199
200/** `base`, or `base-2`, `base-3`, ... until `taken` says no. */
201export function uniqueId(base: string, taken: (id: string) => boolean): string {
202 if (!taken(base)) return base
203 for (let n = 2; n < 10000; n++) {
204 const suffix = `-${n}`
205 const cand = base.slice(0, 48 - suffix.length).replace(/-+$/g, '') + suffix
206 if (!taken(cand)) return cand
207 }
208 return `${base.slice(0, 40)}-${Math.floor(Math.random() * 1e8).toString(36)}`
209}
210
211// ---------------------------------------------------------------- parsing
212
213export type ParsedEvent = { event: DecisionEvent } | { problem: string }
214
215/** Reads one event file's text. The op-specific fields are checked when it is replayed. */
216export function parseEventText(text: string): ParsedEvent {
217 let raw: unknown
218 try {
219 raw = JSON.parse(text)
220 } catch {
221 return { problem: 'not valid JSON' }
222 }
223 if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { problem: 'not a JSON object' }
224 const o = raw as Record<string, unknown>
225 if (o.v !== 1) return { problem: `unknown version ${JSON.stringify(o.v)}` }
226 if (typeof o.op !== 'string' || !(OPS as readonly string[]).includes(o.op)) {
227 return { problem: `unknown op ${JSON.stringify(o.op)}` }
228 }
229 if (typeof o.ts !== 'string' || o.ts === '') return { problem: 'missing ts' }
230 if (!isActor(o.actor)) return { problem: `unknown actor ${JSON.stringify(o.actor)}` }
231 return {
232 event: {
233 ...(o as Partial<DecisionEvent>),
234 v: 1,
235 op: o.op as DecisionEvent['op'],
236 ts: o.ts,
237 actor: o.actor,
238 session: typeof o.session === 'string' ? o.session : 'external',
239 },
240 }
241}
242lib/forest.ts 206 lines1// The project's trees side by side: the facts the tabs and the overview are drawn from (one `DtTreeInfo` for
2// each tree), the order the trees are listed in, and the graph of their steps over time.
3//
4// A tree opened from another begins as a copy of its events up to some point. What it records after that is
5// its own. The overview draws only what each tree recorded itself, so no step is drawn twice, and joins the
6// lane of a tree to the lane it came from, at the step it was opened at.
7//
8// ● Notes app: offline-first rewrite main
9// ●───╮ Storage: SQLite with a write-ahead log main
10// ┃ ● Sync: the server owns the data cloud-first
11// ●───╮ ┃ Sync: clients send operations main
12// ┃ ┃ ● CRDT text engine: merge by character crdt-engine
13
14import type { DecisionEvent, DtForkBase, DtGraphStep, DtModel, DtStatus, DtTreeInfo } from '../types'
15import { MAIN, PENDING_STATUSES, parseTime } from './events'
16import { buildIndex, stepNodes } from './tree'
17import { numbered } from './versions'
18
19/** The most steps of one tree the overview keeps (the newest ones): a tree of hundreds of steps is not a picture. */
20export const MAX_OWN = 40
21/** The most trees a pane lists. */
22export const MAX_TREES = 40
23
24/** Whether the node an event file added was recorded by the tree itself, and not copied in when the tree was opened. */
25export function isOwn(fork: DtForkBase | null, addedBy: string): boolean {
26 return fork === null || fork.at === null || addedBy > fork.at
27}
28
29/** Whether a step is work still ahead: the line that leads to it is dashed. */
30export function isAhead(status: DtStatus): boolean {
31 return (PENDING_STATUSES as readonly string[]).includes(status)
32}
33
34/** Everything the tabs and the overview say about one tree, worked out from its replayed model and its event files. */
35export function treeInfoOf(name: string, model: DtModel, entries: readonly { name: string; event: DecisionEvent }[]): DtTreeInfo {
36 const idx = buildIndex(model)
37 const steps = stepNodes(idx)
38 // the head is the newest step that is adopted, as in the rail
39 const head = steps.reduce((at, n, k) => (n.status === 'adopted' ? k : at), -1)
40 const own: DtGraphStep[] = []
41 steps.forEach((n, k) => {
42 if (!isOwn(model.fork, n.addedBy)) return
43 own.push({ id: n.id, title: n.title, status: n.status, at: n.decidedAt ?? n.createdAt, by: n.addedBy, head: k === head })
44 })
45 const last = entries.length > 0 ? entries[entries.length - 1]!.event : null
46 return {
47 name,
48 // what a tree is for: the title it was opened with; the first tree has none, and says what its oldest step says
49 title: model.fork === null ? (steps[0]?.title ?? null) : (model.fork.title ?? null),
50 fork: model.fork === null ? null : { tree: model.fork.tree, at: model.fork.at, count: model.fork.count },
51 nodes: model.nodes.filter(n => n.retracted === undefined).length,
52 steps: steps.length,
53 events: numbered(entries).length,
54 lastEventAt: model.lastEventAt,
55 lastActor: last === null ? null : last.actor,
56 own: own.slice(-MAX_OWN),
57 }
58}
59
60// ---------------------------------------------------------------- the order
61
62/** The tree a tree is drawn under: the one it was opened from when that is listed, else the first tree. */
63function parentName(info: DtTreeInfo, byName: ReadonlyMap<string, DtTreeInfo>): string | null {
64 if (info.name === MAIN) return null
65 const base = info.fork?.tree
66 if (base !== undefined && base !== info.name && byName.has(base)) return base
67 return byName.has(MAIN) ? MAIN : null
68}
69
70/**
71 * The order the trees are listed in, for the tabs, the lanes and the colours: the first tree, then what was
72 * opened from it, the newest point of it first (the lane of a later fork sits next to the lane it left, so the
73 * lines of the graph do not cross), each followed by the trees opened from it in the same way.
74 */
75export function orderTrees(infos: readonly DtTreeInfo[]): DtTreeInfo[] {
76 const byName = new Map(infos.map(i => [i.name, i] as const))
77 const kids = new Map<string | null, DtTreeInfo[]>()
78 for (const info of infos) {
79 const p = parentName(info, byName)
80 const list = kids.get(p)
81 if (list === undefined) kids.set(p, [info])
82 else list.push(info)
83 }
84 const later = (a: DtTreeInfo, b: DtTreeInfo) => (b.fork?.count ?? 0) - (a.fork?.count ?? 0) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
85 for (const list of kids.values()) list.sort(later)
86 const out: DtTreeInfo[] = []
87 const seen = new Set<string>()
88 const walk = (info: DtTreeInfo): void => {
89 if (seen.has(info.name)) return
90 seen.add(info.name)
91 out.push(info)
92 for (const k of kids.get(info.name) ?? []) walk(k)
93 }
94 // the first tree leads; trees that name each other as their base (a corrupt record) are put at the end
95 for (const root of kids.get(null) ?? []) walk(root)
96 for (const info of [...infos].sort(later)) walk(info)
97 return out
98}
99
100// ---------------------------------------------------------------- the graph
101
102/** The step of a tree's base a tree's lane leaves from: the last step it was given, and the tree that recorded it. */
103export type ForkPoint = { tree: string; step: string }
104
105/**
106 * Where the lane of `info` leaves another lane: the last step of the tree it was opened from, as that tree
107 * was at the version it was opened at. When the base had recorded nothing of its own by then, what the tree
108 * began with was its base's own copy, and the lane leaves from where that came from. Null when there is no such step.
109 */
110export function forkPoint(info: DtTreeInfo, byName: ReadonlyMap<string, DtTreeInfo>): ForkPoint | null {
111 let fork = info.fork
112 if (fork === null || fork.at === null) return null
113 let limit: string = fork.at
114 const seen = new Set<string>([info.name])
115 while (fork !== null && !seen.has(fork.tree)) {
116 seen.add(fork.tree)
117 const base = byName.get(fork.tree)
118 if (base === undefined) return null
119 const mine = base.own.filter(s => s.by <= limit)
120 if (mine.length > 0) return { tree: base.name, step: mine[mine.length - 1]!.id }
121 // nothing recorded there itself yet: its steps up to the point it was opened at are another tree's
122 if (base.fork === null || base.fork.at === null) return null
123 if (base.fork.at < limit) limit = base.fork.at
124 fork = base.fork
125 }
126 return null
127}
128
129export type GraphRow = {
130 tree: string
131 /** The column of the lane in the graph: 0 is the first tree's. */
132 lane: number
133 step: DtGraphStep
134 /** The first and the last of the lane's own steps. */
135 first: boolean
136 last: boolean
137 /** The step is work still ahead: the line that leads to it is dashed. */
138 ahead: boolean
139 /** The lanes that are joined to this one at this row: the trees opened from this step. */
140 forks: number[]
141}
142
143export type Graph = {
144 /** The trees that have a lane (they recorded a step of their own), in the order of the lanes. */
145 lanes: string[]
146 /** One row for each step, the oldest at the top. */
147 rows: GraphRow[]
148}
149
150/**
151 * The graph of the steps of every tree: a lane for each tree that recorded something of its own, a row for each
152 * of those steps, in the order they were decided, and where each lane joins the one it came from. A step is not
153 * placed before the step its tree was opened from, whatever date it was decided on.
154 */
155export function graphOf(infos: readonly DtTreeInfo[]): Graph {
156 const ordered = orderTrees(infos)
157 const byName = new Map(ordered.map(i => [i.name, i] as const))
158 const trees = ordered.filter(i => i.own.length > 0)
159 const laneOf = new Map(trees.map((i, k) => [i.name, k] as const))
160 const depthOf = (info: DtTreeInfo): number => {
161 let depth = 0
162 let at: DtTreeInfo | undefined = info
163 const seen = new Set<string>()
164 while (at !== undefined && at.fork !== null && !seen.has(at.name)) {
165 seen.add(at.name)
166 depth += 1
167 at = byName.get(at.fork.tree)
168 }
169 return depth
170 }
171
172 type Placed = { row: GraphRow; key: number; depth: number; order: number; at: number }
173 const placed: Placed[] = []
174 const keyOf = new Map<string, number>()
175 const points = new Map<string, ForkPoint | null>()
176 ordered.forEach((info, order) => {
177 const lane = laneOf.get(info.name)
178 if (lane === undefined) return
179 const point = forkPoint(info, byName)
180 points.set(info.name, point)
181 // every step comes after the one its tree was opened from, and after the step before it
182 let floor = point === null ? Number.NEGATIVE_INFINITY : (keyOf.get(`${point.tree}\u0000${point.step}`) ?? Number.NEGATIVE_INFINITY)
183 info.own.forEach((step, at) => {
184 floor = Math.max(floor, parseTime(step.at) ?? 0)
185 keyOf.set(`${info.name}\u0000${step.id}`, floor)
186 placed.push({
187 row: { tree: info.name, lane, step, first: at === 0, last: at === info.own.length - 1, ahead: isAhead(step.status), forks: [] },
188 key: floor,
189 depth: depthOf(info),
190 order,
191 at,
192 })
193 })
194 })
195 placed.sort((a, b) => a.key - b.key || a.depth - b.depth || a.order - b.order || a.at - b.at)
196 const rows = placed.map(p => p.row)
197 for (const info of trees) {
198 const point = points.get(info.name)
199 if (point === null || point === undefined) continue
200 const row = rows.find(r => r.tree === point.tree && r.step.id === point.step)
201 if (row !== undefined) row.forks.push(laneOf.get(info.name)!)
202 }
203 for (const row of rows) row.forks.sort((a, b) => a - b)
204 return { lanes: trees.map(i => i.name), rows }
205}
206