SLOPSHOPPER

decision-tree

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

newpanebandguardcommandtoast
v0.4.0MITupdated 2026-10-14xwzhang98/decision-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decision-tree
│ ┃ Decisions ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ ⏺ Read(src/auth.ts) │ ┃ No decisions are being recorded in this p… ⎿ Read 6 lines │ ┃ Run /decide init to have Claude find the … ⏺ Update(src/auth.ts) │ ┃ point, /decide start for an empty tree, o… ⎿ Added 2 lines, removed 1 line │ ┃ recording decisions from now' in a prompt. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tree │ ⎿ decision-tree: Opened the decision pane │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Decisions
app ~lang-en ~ No decisions are being recorded in this project yet. Run /decide init to have Claude find the starting point, /decide start for an empty tree, or say 'start recording decisions from now' in a prompt.
README

decision-tree

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.

The pane: the mainline as a rail with what was tried hanging off it, and the details of the selected decision in a frame below

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.

  • Claude writes it down, you keep the say. Decisions are recorded by Claude, as you work, through tools the mod registers. You can also change them with commands, or by hand in the pane.
  • A rail and a frame. The mainline is drawn as the spine of a graph, with what was tried and dropped hanging off it. Below it, a frame tells the whole story of the selected decision: rationale, evidence, sources, notes. Words that matter (numbers, dates, names, quotes) are colored the way code is.
  • Several trees. Open a new tree from the current version, or from any earlier version, and keep recording there (like branching in git). The original stays untouched. Tabs switch between trees, and a Trees overview draws them as a graph. See Several trees.
  • English and Chinese, switchable at any time. Every decision can be read in English; one key (l) or a click on EN 中 switches the pane. In Chinese, professional names and everyday work words (bug, doc, commit, …) stay English. See Language.
  • 16 color schemes. Your terminal's own colors by default, so the pane looks like the rest of your terminal; or One Dark, Nord, Gruvbox, Dracula, Catppuccin, … Press c. See Colors.
  • The record lives in the project. The data is plain text in .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.
  • A "Now" block (optional) above the tree: a status command that the project configures itself (it prints JSON) is refreshed every 30 seconds and shows long-running jobs, their estimated finish times and the next gate.
  • It contains no project names, paths or machine names. Everything specific to a project lives in that project's own 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.

Contents

  1. Prerequisites
  2. Installation
  3. Getting started
  4. Commands
  5. The pane
  6. Several trees
  7. Language
  8. Colors
  9. The "Now" block and the status command
  10. Ghostty and other terminals
  11. Tools for the model
  12. Data format
  13. Behavior to know about
  14. Development and tests
  15. Using it in your own project
  16. Implementation notes and assumptions
  17. Third-party notices
  18. Authors
  19. License

Prerequisites

  • Written against the mod API of Claude Code 2.1.296 (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.
  • With the fullscreen terminal layout the pane docks on the right and shares the space with the diff panel as tabs. The width rules belong to the engine. When a project is already recording, the mod opens the pane on its own, and it only draws once the terminal is at least 144 columns wide (110 is enough after you have opened it once with /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).
  • It needs no external program. The only external commands it runs are git rev-parse and git check-ignore (to find the project root and to check .gitignore), and the status command you have approved.
  • Its one use of the network is the optional background translation: the texts of your decisions that lack a version in the other language are sent to your session's own model, with your session's own credentials. Nothing else leaves the machine. /decide translate off stops it; see Language.

Installation

From GitHub

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

On a remote server (without interrupting running sessions)

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

  1. Copy the whole folder to the server, for example to ~/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.)

  1. In that new Claude Code session type:
   /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.

  1. To change the code later, edit ~/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

Try it without installing

claude --plugin-dir ~/mods/decision-tree

It applies to that one session only, and reloads by itself when you edit the files.

Getting started

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:

  • run /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;
  • say "start recording decisions from now" in a prompt (the Chinese "从现在开始记录决定" works too) and the model calls decision_log_start;
  • or run /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/

Setting up from the project itself

/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.
  1. If recording is off, the mod turns it on (like /decide start, except that the pane does not take the keyboard).
  2. A moment later it hands Claude a task, as if you had typed it: look through the README and docs, notes, 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.
  3. Claude records the starting point with 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.
  4. Then, under it, up to seven decisions the project has clearly already made: 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.
  5. It ends with two or three sentences on what it recorded and what it could not tell.

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.

Commands

CommandWhat it does
/treeOpens 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 resumePause, 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 treeList 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

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:

  • The strip. The project's name in bold, then, once the project has more than one tree, a tab for each (see Several trees): the tree you are looking at is bold and underlined, and the tree recording goes to has a green ●. At the right, ◌ translating 14 while texts are being translated, and the language switch EN 中, whose current language is bold and underlined (see Language).
  • The state line. How many decisions the tree holds and how long its mainline is, whether it is recording (● 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.
  • The origin line, for a tree that was opened from another one, from 16 rows up: ↳ main #5 (09-21) CRDT text engine instead of paragraph merge, where it began and what the tree is for.
  • The rail. The mainline drawn as the spine of a graph, one step per row, with what was tried at each step hanging off it (below). The date at the right of a step is when it was decided.
  • The details of the selected row, in a frame: its title, summary, then Rationale, Evidence, Sources and Notes under labels of their own. The top edge carries a chip with the status, who decided it (user, model, gate) and the date. When the node replaces or is replaced by another one, a line says so.
  • The key hints. Each is a button: press the key, or click it. A key that is not drawn does not work, so in a narrow pane the least useful ones go first (up to three lines of them from 24 rows, two from 12, one below that). The count of warnings (event files that could not be read, say) appears at the right end only when there are any; 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.

The rail

MarkMeaning
●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
+3Three rows folded away under this one
▌The selected row (with a band across the pane)
▲ 5 more above ▼ 6 more belowThe 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):

statussymbolASCIIcolor
adopted✓+green
rejected✗xred
no-diff==gray
in-progress◐~accent
planned○oaccent
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:

KindExamplesColor
Topicthe part of a title before its first colon (Sync:), and file names in Sourcesblue
Number99.5%, 5 s, 5,000, #5yellow
TermSQLite, CRDT, proto_b (ALLCAPS, snake_case, camelCase)cyan
Quote"soak test"green
Date09-21, 15:28gray

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 
Source 42 files
hooks/register.tsx 165 lines
1// 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}
165
lib/port.ts 65 lines
1// 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}
65
lib/runtime.ts 1979 lines
1// 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 lines
1// 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[] = []
40
lib/palettes.ts 223 lines
1// 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}
223
lib/tools.ts 334 lines
1// 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}
334
lib/view.tsx 361 lines
1// 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}
361
lib/bar.ts 126 lines
1// 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}
126
lib/config.ts 125 lines
1// `.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}
125
lib/commands.ts 295 lines
1// 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}
295
lib/events.ts 242 lines
1// 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}
242
lib/forest.ts 206 lines
1// 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