Coherence's hooks through one warm process per session, with its verdicts where the user reads them

See what your agents actually built, and know the moment it stops matching what you meant.
Agents write code faster than anyone can read it. They are trained to finish the job, so they cut subtle corners where you can't see them: a read that skips the session check, a word used in the wrong sense, a decision made three hundred thousand tokens into a transcript nobody will open again. The usual answer is more inference: one agent writes the code, another reviews it, and the findings come back as whack-a-mole.
Coherence takes a different route. It makes your intentions part of the project's structure, checks them with the same language server your editor uses, and shows you the result as a live map of the system. Agents get the right context at the start of every session and hear about a violation in the same turn they cause it. You get to see the project as it is right now, without sifting through thousands of lines to find out.
The less inference you spend on code that doesn't work, on finding bugs, and on reconstructing why a change was made, the more you have left for the work itself.

Install @posthog/coherence@latest as a dev dependency (with pnpm, if minimumReleaseAge holds it back, add it to minimumReleaseAgeExclude) and run `npx --no -- coherence hooks install --host claude` (or `--host codex`).
Then run `npx --no -- coherence query practice "adopt Coherence"`, work through it, record it with `enact`, and tell me what each step found.
Ask me whether pull requests should fail on Coherence's findings or get a report comment, then run `npx --no -- coherence ci install --host github --mode fail` (or `--mode comment`) with `--write`.
It needs Node 22.18 or newer on macOS (Apple Silicon) or Linux. The package is @posthog/coherence on npm, published from this repository with provenance; the dev dependency is the one way to set Coherence up, and every session start names the Coherence that runs, its version and where it is. A clone kept beside the project is no longer a way to run it: the hooks never do.
coherence scope opens Scope, a live page that projects the whole model of your project for a human. It leads with one verdict (all verified, nothing enforced yet, or how many invariants are broken), and its first view is Structure: a transit-style map of what the system is made of and how work flows through it.
Every line comes from resolved references the language server reports, not from a diagram someone drew and forgot to update. When an agent changes how components relate, the map changes with it.
Select anything to trace it. Selecting an entrance lights its route and dims the rest, and the side bar lists every interface along the way and how many reference sites back each one:

When a chokepoint breaks, the component wears a red mark listing every bypass site, and the map opens on the worst one. An entrance whose untrusted input reaches the system with no verified chokepoint or totality oracle traced on its route is flagged no traced control (see scope http above): not a demonstrated bypass, but a place to look. A chokepoint counts where the reading sees the handler pass it; a totality oracle counts only on the entrances its invariant names (entrances:), never on every entrance whose trust its crossing matches. Selecting a trust level lights every crossing that carries it, so you can see where sensitive data goes.
Language models are built out of language, and an ambiguous word produces an ambiguous implementation. Coherence keeps the project's settled vocabulary in a lexicon: each concept with its definition, its aliases, and the names rejected for it and why. Your agent's own hooks deliver it at the start of every session and subagent, so agents do what you meant, not just what you said. coherence lexicon --check finds a rejected name anywhere in prose, specs, journal records, or identifiers, and coherence lexicon coverage ranks the recurring terms that still lack a definition; give it folders or files (coherence lexicon coverage src/billing docs) to read only those.
Each concept in Scope's Lexicon view carries its definition, the alternatives that were rejected and why, and its related concepts. When an agent reaches for a rejected name, the record of why it was rejected is already there:
![]()
Each component's spec states its invariants in one plain bullet grammar, along with the reason each one matters. An invariant counts only when it has enforcement and a witnessed refutation: someone broke it on purpose and the detector went red.
sessions.get() in front of the session store. The chokepoint check asks the language server for every reference and flags any that go around it. It also stages fake bypasses, without touching your files, to prove it would fire; a check that would miss one is reported as vacuous, not passing. A module boundary adds from: outside the component, so the module's own code may use its internals; those references are reported as exempt, never dropped.entrances: line names.
Coherence runs inside your agent host (Claude Code or Codex) through its hooks. The moment an agent writes a file that may touch a chokepoint, the check runs again through a warm language server and reports any bypass in the same turn, by file and line, with the two honest options: route through the chokepoint, or escalate to a human. At the end of a session, the regulate step reports what the session still owes; a subagent's stop is refused only for what the tool can prove.
A host shows a main-thread stop hook's message to you and never to the agent, so regulate says each line once per session and puts it where the agent reads it. A practice the session fired and never recorded holds the stop once, with the reason in front of the agent; everything else is shown to you and carried into the agent's next prompt.
The hooks around every tool call are the cost every session pays, and a call cycle that feels sluggish is how a check gets switched off. Each hook call's time is kept. A tool hook (PreToolUse, PostToolUse) over the latency budget, 3 s unless latencyBudget says otherwise, says so in its own answer, and regulate and orient name the calls over it. A tool use that writes nothing reads no vocabulary, a prompt over an unchanged tree reads none either, and a command or edit outside every adopted project costs a constant: on this repository a tool hook takes about 0.3 s, and 0.4 to 2.4 s in the PostHog monorepo with two products adopted.
In Claude Code, Coherence can also show its own line in the status line, for you rather than the agent: the version running, the session's tool hook time against the budget, and the practices it owes.
coherence <version> · hooks 0.4 s avg · 1 practice owed: close a defect
It reads only the session's own records, about 0.1 s a render. Add it to .claude/settings.json, or to ~/.claude/settings.json for every project:
{ "statusLine": { "type": "command", "command": "npx --no -- coherence-statusline" } }
While Coherence's warm server is serving Scope (after coherence scope), the line ends with a scope ↗ link to the live page, in terminals that draw hyperlinks (iTerm2, Kitty, WezTerm). The address carries the project's token, so it is only ever the link's target, never printed.
If you already have a status line, keep it by passing its command after --: npx --no -- coherence-statusline -- ~/.claude/statusline.sh prints yours first, then Coherence's.
In Claude Code you can also run Coherence's hooks through its mod instead of the settings hooks. The mod starts one Coherence process per session and sends it every event, so an event no longer pays Node's start and Coherence's module load, about 0.25 s each. In the PostHog monorepo a prompt, a read or an edit outside every adopted project drops from about 260 ms to under 10 ms. The expensive steps (a project's first entry, a stop after an edit) cost about the same either way.
The mod also shows Coherence's line in the status line, marks each edit's tool row with its check's verdict, and puts a Scope ↗ button above the prompt (or run /scope). The agent still gets exactly what the settings hooks give it.
Beside it, Journal (or /journal [session]) opens a pane that tells the journal as it is written: each decision, defect, conjecture and escalation in its own words, folded to one line, over a small tree of the files it touched. By default it streams the latest records live across every session and every git worktree of the repository, background agents' included, each under its agent and session; /journal <session> shows one session's and /journal live goes back. The agent can point you at a record through the mcp__coherence__journal tool.
npx --no -- coherence hooks install --host claude --mod
This is personal to you in this project. It registers Coherence's repository as a Claude Code plugin marketplace and installs the mod from it, both in .claude/settings.local.json. The mod runs the project's own installed Coherence, so it works with whatever version the project uses. It also removes your personal Coherence settings hooks, which the mod replaces. Committed hooks belong to the team and stay; while they are there, the mod leaves the events they cover to them, and install names those events. To move the whole team, hooks install --host claude --mod --shared enables the mod in the committed .claude/settings.json in place of the committed Claude Code hooks; each teammate is asked to install it at their next session. coherence hooks uninstall --host claude removes the mod too. Claude Code updates the mod with claude plugin update coherence@coherence. Codex keeps the settings hooks. The measurements and the mod's limits are in docs/mods-spike.md.
The journal compresses hundreds of thousands of session tokens into a handful of durable records: decisions with the alternatives rejected and why, conjectures, defects, experiments, and escalations for a human. Every record carries its session and agent. If a bad call was made along the way, it's right there in the history instead of buried in the code, and a later session can retract it and say what refuted it.
The journal, the runs and the work orders are committed with the code, as append-only files per session under .coherence/. A writer starts a new file (a sealed shard) whenever HEAD or the branch moves, so no file is ever written from two branches, and parallel or stacked branches that recorded in the same session land by merge, squash or rebase on GitHub without a conflict (docs/records-layout.md). You don't have to do anything to get it.
Here is one decision as the Journal view shows it: what was chosen, the alternatives rejected, the reason, the human words that prompted it, and the earlier decision it builds on.

Every project has ways of working that live only in someone's head or a scrollback: how to witness a refutation, how to carry a lexicon change through, how to merge a parallel worktree without losing records. Each agent session starts with none of them. A practice keeps one: a file beside a component's spec (Enforcement.practice.md beside Enforcement.spec.md) lists the steps, the evidence each step leaves, and the pitfalls, each citing the record that witnessed it.
- witness a refutation: A refutation counts only when the detector went red because of the staged break.
when: command refute | edit **/*.spec.md adding refuted:
step: run the bullet's test alone, by its filter, and see it green
leaves: run record for the bullet, pass
step: stage the smallest break that changes the behavior the bullet claims
pitfall: a sed matched two lines, so the test hung and went red only when killed (d-828ddc83)
learned: d-4dafa61b, df-b9b2711b
because: the step that slips is the one no command checks: that the red came from the break
A leaves: line belongs to the step above it whatever its indentation; a formatter that flattens it to two spaces changes nothing.
When a tool use is about to fire a practice's trigger, the hook delivers the practice whole, before the act. The session records what it did with enact: every step done, deviated, or skipped, with why. Once a practice has been enacted, a step can only leave it with a decision that says why, so a method improves through recorded deviations instead of eroding through silent ones. Coherence ships kernel practices for its own commands and for adoption itself, so an adopter starts with them; each of Coherence's practices declares reach: kernel or reach: internal.
Move between conversations and agents without writing summary files for the next session. Work orders give each unit of work an owner, an objective, and a file boundary, and journal records and runs bind to them automatically. The peer feed drops the subjects of other sessions' new decisions into each agent before its next tool use, so subagents compare notes as they go.
coherence economy <path> predicts what a reader must load to change something safely, with a token estimate. coherence calibrate checks that prediction against what sessions actually read, and coherence mass reports how much code no invariant's enforcement reaches yet.
It needs Node 22.18 or newer on macOS (Apple Silicon) or Linux.
Set up Coherence in this project, and report what each step found.
1. Install. Run `npm install -D @posthog/coherence@latest` (or `pnpm add -D
@posthog/coherence@latest`); the steps below use what the latest release
has. If pnpm's minimumReleaseAge refuses it or resolves an older one, add
`@posthog/coherence` to `minimumReleaseAgeExclude` in pnpm-workspace.yaml
(bun: `minimumReleaseAgeExcludes`; Yarn: `npmPreapprovedPackages`). Install
the package itself, never a checkout. The command is
`npx --no -- coherence` (call it `coherence` below); `--no` keeps npx from
fetching an unrelated package if the install is missing, and `--` keeps it
from taking Coherence's flags, such as `--version`, as its own. Confirm
with `coherence --version`. Teammates get it from the lockfile.
2. Hooks. Run `coherence hooks install --host claude` (or `--host codex`).
From the next session on, every session starts with Coherence's
vocabulary, the project's standing, and the exact journal commands.
3. Adopt. Run `coherence query practice "adopt Coherence"` and work through
it: config, vocabulary, specs and entrances, the invariants that matter
most, refutations, baselines, and the methods the project already has.
Each step names the practice or command that carries it; a practice is
delivered whole when its command is about to run. The references it
points at ship with the package: docs/config.md (every config key) and
docs/spec.md (the spec and practice grammar). When done, record it:
`coherence enact "adopt Coherence" --step <n>=done|deviated:<why>|skipped:<why> ...`.
4. CI. Ask me whether pull requests should fail on Coherence's findings or
get one report comment, then run `coherence ci install --host github
--mode fail` (or `--mode comment`) with `--write`.
Record every non-obvious choice with `coherence decide "<chose>" --over
"<rejected>" --because "<why>" --session <id> --agent <name>`.
Adoption is a practice rather than prose so that its steps arrive where their commands run, its pitfalls cite what went wrong in earlier adoptions, and the enactment is a durable record of how the project was adopted (coherence query practice lists every practice).
The installed hooks run the project's installed package; where it is not installed, the session start says so in one line and tells the agent how to install it. Each session start names the Coherence that runs and its version. Hooks an earlier release wrote also looked at a ../coherence clone beside the project, before the installed package, and keep running that clone until they are reinstalled: if one sits beside your project, run npx --no -- coherence hooks install --host claude (or --host codex) after upgrading, since the clone cannot tell you so. Once the installed package runs, the session start and coherence hooks --check name hooks of that earlier form with the same command. To run a project against a Coherence checkout you are developing, see Working on Coherence itself. To add the project's own words to an event, write .coherence/hooks/<Event>.append.md (it follows what the hook says) or .coherence/hooks/<Event>.override.md (it replaces it; an empty one silences the event). {{session}}, {{agent}} and {{cli}} are filled in; a refused subagent stop keeps its reason whatever the override says.
Coherence can own one folder of a large repository, or several, and leave the rest alone: an edit, command or prompt outside every adopted folder gets no orient, practice or check, leaves no record, and costs a constant.
Try it on one folder, alone. Put coherence.config.json, the specs and .coherence/ in the folder (say products/billing), and nothing at the repository root. Run coherence hooks install --host claude --local from that folder: it writes your personal .claude/settings.local.json at the root, so nobody else's sessions change. (Without --local it writes the shared .claude/settings.json at the root, where a host started there reads it. --local is Claude Code only; Codex has no personal settings file.) A session at the root hears one line at its start and gets the folder's orient with its first edit or command inside it.
Commit to it, folder by folder. Run coherence adopt <folder> for each folder a team takes on. It lists the folder under projects in a coherence.config.json at the repository root: a registry, not a project. Every listed folder inherits the registry's keys (languages, test setups, ignore, references, latencyBudget) unless its own config overrides one, and keeps its own specs, journal and runs. Hooks are installed once, at the root. A nested config the registry does not list is named by spec --check as adopted but not opted in. adopt will not turn a whole-repository config into a registry; that move is yours to make by hand.
Callers outside the folder. A product boundary's bypasses usually live elsewhere in the repository. By default a chokepoint's reference search covers the adopted folder only, and its verdict says so. references widens it: "repository", or a list of folders such as ["posthog/api", "ee"].
Boundaries the repository already declares. Where a tach.toml declares the modules, coherence scaffold import tach <module> drafts each one's spec: its dependencies, its exposed facade as a chokepoint governing references from outside the component (from: outside the component), and its owners from product.yaml.
Paste this into your agent to adopt one folder:
Adopt Coherence for <folder> only. Install @posthog/coherence@latest as a dev dependency (with pnpm, if minimumReleaseAge holds it back, add it to minimumReleaseAgeExclude) and run `npx --no -- coherence hooks install --host claude --local` from <folder>.
Run `npx --no -- coherence adopt <folder>` from the repository root if this repository keeps a registry of adopted folders.
Then run `npx --no -- coherence query practice "adopt Coherence"` from <folder>, work through it, record it with `enact`, and tell me what each step found.
Ask me whether pull requests should fail on Coherence's findings or get a report comment, then run `npx --no -- coherence ci install --host github --mode fail` (or `--mode comment`) with `--write`.
Every key is in docs/config.md.
Coherence runs on Node 22.18 or newer, on macOS (Apple Silicon) or Linux, and reads TypeScript, Python and Rust projects, and projects that span more than one (a Python backend beside a TypeScript frontend: list both languages and a test setup per runner, as docs/config.md shows). Rust chokepoints are graded through rust-analyzer, found on the machine rather than installed by npm (docs/enforcement.md); the economy and Scope readings do not read Rust yet, and say so by name. It works inside Claude Code and Codex. Where the project already runs a checker that refuses a boundary crossing, Coherence grades by it: Pyright's private-usage rule, an import-linter rule, or a tach.toml interface (where tach's own check refuses a planted outside import, and the chokepoint is graded checker-choked by tach). Coherence's own reference search still covers what such a checker cannot see. Coherence verifies itself: every screenshot here is its own Scope reading.
The language adapters ask tsserver, Pyright and rust-analyzer for references. Where a Python project runs a checker that refuses a bypass, the chokepoint is graded on that checker: Pyright's reportP
hooks/register.tsx 431 lines1// Coherence in Claude Code through one warm process per session (spike).
2//
3// The settings hooks spawn `coherence hook <Event>` for every event, paying
4// for Node and Coherence's code each time. This mod starts `coherence
5// hook-serve --socket` once, at the session's start, and answers the seven
6// classic events Coherence hooks through it: the same runHook, the same
7// output, the same records. Whatever the warm process cannot answer (not up
8// yet, gone, its code changed on disk) runs through `coherence hook <Event>`
9// as before, and a process that went down is said to the user.
10//
11// What reaches the agent is what Coherence's hooks give it (context, a
12// block, a refusal); what only the user reads goes to the user's channels:
13// the stop's digest under the answer, a verdict on an edit's row, the hook
14// time and practices owed in the status line. Coherence refuses and informs;
15// it never grants a tool call.
16//
17// The band above the prompt holds two buttons for the person: Scope, the live
18// reading in the browser, and Journal, the session's journal told in a pane
19// (journal-pane.tsx), each record with a small tree of the files it touched.
20
21import type { EngineInterface as Engine, Register } from 'claude-code'
22import type { CoherenceVerdict } from '../types'
23import { WRITER_TOOLS } from './writers.ts'
24import { EVENTS, request, settingsRunCoherence, translate, verdictLine, type Classic, type CoherenceEvent, type HookAnswer } from './answer.ts'
25import { JOURNAL_COMMAND, JOURNAL_PANE, JOURNAL_TOOL, journalPane } from './journal-pane.tsx'
26import { place } from './place.ts'
27
28/** The tools whose PreToolUse Coherence's settings hook is installed for: the tools that write, as host-tools.ts declares them. */
29const PRACTICE_TOOLS = new Set(WRITER_TOOLS)
30/** How many times a session restarts a warm process that went down before it stays on the CLI. */
31const RESTARTS = 2
32/** How long a stop's digest waits for the turn's end to show it, before it is logged instead. */
33const DIGEST_WAIT_MS = 3000
34/** The keys a tool call's envelope carries that are not the tool's own input. */
35const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'requestMeta'])
36const NODE = ['node', '--disable-warning=ExperimentalWarning']
37
38type Warm = { state: 'off' } | { state: 'starting' } | { state: 'warm'; socket: string } | { state: 'down'; reason: string }
39type Via = 'warm' | 'cli'
40
41/** The session's own: a reload of the module starts it over, and the warm process with it (its loop ends with the module). */
42const held = {
43 warm: { state: 'off' } as Warm,
44 restarts: 0,
45 cli: undefined as string | undefined,
46 root: '',
47 seq: 0,
48 digest: undefined as string | undefined,
49 /** The last subagent stop's digest said, so the next identical one is not said again. */
50 lastSubagentDigest: undefined as string | undefined,
51 last: {} as { session_id?: string; cwd?: string },
52 aside: new Set<CoherenceEvent>(),
53 times: [] as { event: string; via: Via; ms: number }[],
54 timesFile: undefined as string | undefined,
55 setup: undefined as Promise<void> | undefined,
56 ending: false,
57 /** Which warm process is the current one: a process that ended after another replaced it says nothing. */
58 generation: 0,
59}
60
61/** Set up once, by whichever comes first: the classic SessionStart fires before session.start does. */
62function ready($: Engine): Promise<void> {
63 held.setup ??= setUp($)
64 return held.setup
65}
66
67/**
68 * Where Coherence's command line is: COHERENCE_HOME's checkout; else the
69 * project's own installed Coherence, whatever its version, since this mod is
70 * one per machine and every project keeps its own; else, in Coherence's own
71 * repository, its source; else the checkout this mod was loaded from with
72 * --plugin-dir.
73 */
74async function locate($: Engine): Promise<string | undefined> {
75 const home = await $.env.get('COHERENCE_HOME')
76 const ownRepository = (await $.fs.stat(`${held.root}/mods/coherence/.claude-plugin/plugin.json`).catch(() => undefined))?.kind === 'file'
77 const candidates = [
78 ...(home === undefined || home === '' ? [] : [`${home}/src/cli.ts`]),
79 `${held.root}/node_modules/@posthog/coherence/dist/cli.js`,
80 ...(ownRepository ? [`${held.root}/src/cli.ts`] : []),
81 `${$.plugin.root}/../../src/cli.ts`,
82 ]
83 for (const path of candidates) {
84 const found = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
85 if (found?.kind === 'file' && found.realPath !== undefined) return found.realPath
86 }
87 return undefined
88}
89
90function say($: Engine, text: string): void {
91 $.ui.toast(text)
92 $.ui.log(text)
93}
94
95/**
96 * In Coherence's own repository each tree runs its own mod, linked from the
97 * project's skills folder; an installed copy takes the name first and runs
98 * instead, which nothing else would show. Said once, where the person reads it.
99 */
100async function sayWhenNotOwn($: Engine): Promise<void> {
101 const own = await $.fs.stat(`${held.root}/mods/coherence/.claude-plugin/plugin.json`, { resolve: true }).catch(() => undefined)
102 if (own?.kind !== 'file' || own.realPath === undefined) return
103 const running = await $.fs.stat(`${$.plugin.root}/.claude-plugin/plugin.json`, { resolve: true }).catch(() => undefined)
104 if (running?.realPath === own.realPath) return
105 const installed = running?.realPath?.includes('/.claude/plugins/') === true
106 say($, installed
107 ? `Coherence: an installed copy of the mod (${$.plugin.root}) runs here in place of this tree's own mods/coherence, so edits to it are not loaded; \`node src/cli.ts hooks uninstall --host claude\` removes the copy installed for this repository, and \`claude plugin uninstall coherence@coherence --scope user\` one installed for you.`
108 : `Coherence: the mod running here is ${running?.realPath?.replace(/\/\.claude-plugin\/plugin\.json$/, '') ?? $.plugin.root}, not this tree's own mods/coherence; edits there reload it, edits here do not.`)
109}
110
111async function setUp($: Engine): Promise<void> {
112 held.root = await $.session.root()
113 held.timesFile = await $.env.get('COHERENCE_MOD_TIMES')
114 held.cli = await locate($)
115 // The Journal pane reads where Coherence is from here: data only, never the engine interface.
116 place.root = held.root
117 place.cli = held.cli
118 const hooks = (await $.settings.read()).hooks
119 for (const event of EVENTS) if (settingsRunCoherence(hooks, event)) held.aside.add(event)
120 if (held.aside.size > 0) $.ui.log(`Coherence: the settings hooks already run Coherence for ${[...held.aside].join(', ')}; the mod leaves those to them, since answering too would run each check twice. \`coherence hooks uninstall --host claude\` hands them to the warm process.`)
121 await sayWhenNotOwn($)
122 if (held.cli === undefined) say($, 'Coherence: the mod found no Coherence command line (COHERENCE_HOME, node_modules/@posthog/coherence, or the checkout it ships in); its hooks do nothing this session.')
123 start($)
124}
125
126/** Start the warm process; its loop is the child's life, and its end says the process went down. */
127function start($: Engine): void {
128 const cli = held.cli
129 if (cli === undefined) return
130 held.warm = { state: 'starting' }
131 const generation = ++held.generation
132 void (async () => {
133 let buffer = ''
134 let why = 'it exited'
135 try {
136 const child = $.process.spawn({ argv: [...NODE, cli, 'hook-serve', '--socket'], cwd: held.root, env: { CLAUDE_PROJECT_DIR: held.root } })
137 for await (const { stream, text } of child) {
138 if (stream === 'stderr') {
139 $.ui.log(`coherence hook-serve: ${text}`, { to: 'debug' })
140 continue
141 }
142 buffer += text
143 for (let at = buffer.indexOf('\n'); at >= 0; at = buffer.indexOf('\n')) {
144 const line = buffer.slice(0, at)
145 buffer = buffer.slice(at + 1)
146 try {
147 const said = JSON.parse(line) as { ready?: { socket?: unknown } }
148 if (typeof said.ready?.socket === 'string' && generation === held.generation) held.warm = { state: 'warm', socket: said.ready.socket }
149 } catch {
150 // Not a line of the protocol: the process's own output, kept for the debug log.
151 $.ui.log(`coherence hook-serve: ${line}`, { to: 'debug' })
152 }
153 }
154 }
155 // The host ends the child with SIGTERM as the session or the module ends, and hook-serve exits 0 only when it was asked to
156 // end (a signal, its stdin or parent gone, its code stale, which the stale answer already reported): no failure to report.
157 const ended = await child.result.catch(() => undefined)
158 if (ended?.signal === 'SIGTERM' || ended?.code === 0) held.ending = true
159 else if (ended !== undefined) why = ended.signal === null ? `it exited with code ${ended.code}` : `it was ended by ${ended.signal}`
160 } catch (error) {
161 why = `it could not start (${error instanceof Error ? error.message : String(error)})`
162 }
163 if (generation !== held.generation) return
164 if (held.ending) {
165 held.ending = false
166 held.warm = { state: 'off' }
167 return
168 }
169 down($, why)
170 })()
171}
172
173/** The warm process is gone: say so, and start another while the session has restarts left. */
174function down($: Engine, reason: string): void {
175 if (held.warm.state === 'down') return
176 const again = held.restarts < RESTARTS
177 held.warm = { state: 'down', reason }
178 say($, `Coherence: the warm hook process went down (${reason}); each event runs \`coherence hook\` as a settings hook would${again ? ' while a new one starts' : ' for the rest of this session'}.`)
179 if (again) {
180 held.restarts += 1
181 start($)
182 }
183}
184
185/** One event through the warm process, or through the command line when it cannot answer. */
186async function forward($: Engine, event: CoherenceEvent, input: Record<string, unknown>): Promise<HookAnswer & { via: Via }> {
187 await ready($)
188 const began = await $.clock.now()
189 let answer: (HookAnswer & { via: Via }) | undefined
190 const warm = held.warm
191 if (warm.state === 'warm') {
192 try {
193 const reply = await $.http.fetch('http://coherence/hook', { method: 'POST', socketPath: warm.socket, headers: { 'content-type': 'application/json' }, body: request(++held.seq, event, input) })
194 const said = JSON.parse(reply.text) as Partial<HookAnswer> & { stale?: string; error?: string }
195 if (typeof said.stdout === 'string' && typeof said.stderr === 'string' && typeof said.exit === 'number') answer = { stdout: said.stdout, stderr: said.stderr, exit: said.exit, ...(said.verdict === undefined ? {} : { verdict: said.verdict }), via: 'warm' }
196 else if (typeof said.stale === 'string') down($, 'its code changed on disk')
197 else $.ui.log(`Coherence: the warm process could not answer ${event} (${said.error ?? reply.text.slice(0, 200)}); it ran through \`coherence hook\``)
198 } catch (error) {
199 down($, `unreachable: ${error instanceof Error ? error.message : String(error)}`)
200 }
201 }
202 if (answer === undefined) {
203 const cli = held.cli
204 if (cli === undefined) return { stdout: '', stderr: '', exit: 0, via: 'cli' }
205 const ran = await $.process.run([...NODE, cli, 'hook', event], { cwd: held.root, env: { CLAUDE_PROJECT_DIR: held.root }, stdin: JSON.stringify(input), timeoutMs: 60_000 })
206 answer = { stdout: ran.stdout, stderr: ran.stderr, exit: ran.exitCode, via: 'cli' }
207 }
208 held.times.push({ event, via: answer.via, ms: Math.round((await $.clock.now()) - began) })
209 const file = held.timesFile
210 if (file !== undefined && file !== '') await $.fs.write(file, held.times.map((t) => JSON.stringify(t)).join('\n') + '\n').catch(() => undefined)
211 return answer
212}
213
214/**
215 * A message as one line: a transcript line and the text under an answer are
216 * each drawn on one line, a newline inside them as a stray glyph, and a row
217 * per line would bury the transcript, so its lines are joined: a heading's
218 * colon runs into what follows, and other lines are set apart with " · ".
219 */
220function oneLine(text: string): string {
221 // A heading's colon leads straight into what it heads; separate items are set apart.
222 return text
223 .split('\n')
224 .map((line) => line.trim())
225 .filter((line) => line !== '')
226 .reduce((joined, line) => (joined === '' ? line : `${joined}${joined.endsWith(':') ? ' ' : ' · '}${line}`), '')
227}
228
229/** What only the user reads: a stop's digest waits for the turn's end, anything else is transcript lines now. */
230function tell($: Engine, event: CoherenceEvent, user: string | undefined, debug: string | undefined): void {
231 if (debug !== undefined && debug !== '') $.ui.log(`Coherence ${event}: ${debug}`, { to: 'debug' })
232 if (user === undefined) return
233 const line = oneLine(user)
234 if (event !== 'Stop') {
235 // Every subagent's stop says its digest; one the user has just read is not said again.
236 if (event === 'SubagentStop' && held.lastSubagentDigest === line) return
237 if (event === 'SubagentStop') held.lastSubagentDigest = line
238 $.ui.log(line)
239 return
240 }
241 held.digest = line
242 $.clock.after(DIGEST_WAIT_MS, () => {
243 if (held.digest === line) {
244 held.digest = undefined
245 $.ui.log(line)
246 }
247 })
248}
249
250/** The verdict an edit's check left, for the row's one line. */
251async function keepVerdict($: Engine, toolUseId: unknown, answer: HookAnswer): Promise<void> {
252 const line = verdictLine(answer.verdict)
253 if (line === undefined || typeof toolUseId !== 'string' || toolUseId === '') return
254 const verdict: CoherenceVerdict = line
255 await $.state.set({ plugin: 'coherence', key: 'verdict', id: toolUseId }, verdict)
256}
257
258/** The status line: Coherence's own (version, hook time against the budget, practices owed), then how its hooks are answered. */
259async function refreshStatus($: Engine): Promise<void> {
260 const recent = held.times.slice(-20)
261 // The median, not the mean: a few heavy events (a leaf's first entry, a stop) would pull a mean far from the usual event.
262 const mean = (via: Via): string => {
263 const these = recent.filter((t) => t.via === via).map((t) => t.ms).sort((a, b) => a - b)
264 return these.length === 0 ? '' : `, median ${Math.round(these[Math.floor(these.length / 2)]!)} ms`
265 }
266 const warm = held.warm
267 const route = warm.state === 'warm' ? `warm${mean('warm')}` : warm.state === 'down' ? `cli per event${mean('cli')} (warm process down)` : warm.state === 'starting' ? 'warm process starting' : `cli per event${mean('cli')}`
268 let line = ''
269 if (warm.state === 'warm' && held.last.session_id !== undefined) {
270 try {
271 const reply = await $.http.fetch('http://coherence/status', { method: 'POST', socketPath: warm.socket, body: JSON.stringify({ id: ++held.seq, op: 'status', input: held.last }) })
272 const said = JSON.parse(reply.text) as { status?: unknown }
273 // The host already leads the status line with the mod's name, so the text does not say coherence again.
274 if (typeof said.status === 'string' && said.status !== '') line = said.status.replace(/^coherence /, '')
275 } catch {
276 // The next event finds a process that is gone and says so; the status line keeps its last word until then.
277 }
278 }
279 $.ui.status(line === '' ? route : `${line} · ${route}`)
280}
281
282/** What the agent reads when this mod's own hook failed: the event went on without Coherence's answer. */
283function missed<R extends { additionalContext?: string[] }>(below: R, event: CoherenceEvent, error: { kind: string; message?: string }): R {
284 const why = error.kind === 'timeout' ? 'it ran out of time' : `it failed (${error.message ?? error.kind})`
285 return { ...below, additionalContext: [...(below.additionalContext ?? []), `Coherence's ${event} hook did not answer at this event: ${why}; its checks and records for this event did not run.`] }
286}
287
288function merge<R extends { additionalContext?: string[]; block?: string }>(below: R, mine: Classic): R {
289 const out: R = { ...below }
290 if (mine.additionalContext !== undefined) out.additionalContext = [...(below.additionalContext ?? []), ...mine.additionalContext]
291 if (mine.block !== undefined) out.block = below.block === undefined ? mine.block : `${below.block}\n${mine.block}`
292 return out
293}
294
295/** The classic events but PreToolUse: Coherence's answer and the settings hooks beneath, run side by side and merged. */
296async function classic<R extends { additionalContext?: string[]; block?: string }>($: Engine, event: CoherenceEvent, e: Record<string, unknown>, beneath: Promise<R>): Promise<R> {
297 held.last = { ...(typeof e.session_id === 'string' ? { session_id: e.session_id } : {}), ...(typeof e.cwd === 'string' ? { cwd: e.cwd } : {}) }
298 const [answer, below] = await Promise.all([forward($, event, e), beneath])
299 if (held.cli === undefined && event === 'SessionStart') {
300 return merge(below, { additionalContext: ['Coherence is configured for this project through its Claude Code mod, but the mod found no Coherence command line, so its hooks do nothing this session: no vocabulary, specs, journal or checks. Tell the user; installing @posthog/coherence (or setting COHERENCE_HOME) is theirs to decide.'] })
301 }
302 const { classic: mine, user, debug } = translate(event, answer)
303 tell($, event, user, debug)
304 if (event === 'PostToolUse') await keepVerdict($, e.tool_use_id, answer)
305 return merge(below, mine)
306}
307
308/** PreToolUse arrives as the tool call's envelope, not the settings hook's stdin: the stdin is rebuilt from it and the session. */
309async function preToolUse($: Engine, e: Record<string, unknown>): Promise<Record<string, unknown>> {
310 const input: Record<string, unknown> = Object.fromEntries(Object.entries(e).filter(([key]) => !RESERVED.has(key)))
311 const agent = e.agentId
312 const agentType = typeof agent === 'string' ? (await $.agent.list()).find((a) => a.id === agent)?.type : undefined
313 return {
314 session_id: await $.session.id(),
315 cwd: await $.session.cwd(),
316 hook_event_name: 'PreToolUse',
317 tool_name: e.tool,
318 tool_input: input,
319 tool_use_id: e.tool_use_id,
320 ...(typeof agent === 'string' ? { agent_id: agent, ...(agentType === undefined ? {} : { agent_type: agentType }) } : {}),
321 }
322}
323
324/** /scope: the live Scope page, opened in the browser; the address carries the project's token, so it reaches neither the transcript nor Claude. */
325async function openScope($: Engine): Promise<void> {
326 await ready($)
327 let url: string | undefined
328 let why = 'no Coherence command line'
329 const warm = held.warm
330 if (warm.state === 'warm') {
331 try {
332 const reply = await $.http.fetch('http://coherence/scope', { method: 'POST', socketPath: warm.socket, body: JSON.stringify({ id: ++held.seq, op: 'scope' }) })
333 const said = JSON.parse(reply.text) as { url?: unknown; error?: unknown }
334 if (typeof said.url === 'string') url = said.url
335 else why = String(said.error ?? reply.text.slice(0, 200))
336 } catch (error) {
337 why = error instanceof Error ? error.message : String(error)
338 }
339 } else if (held.cli !== undefined) {
340 const ran = await $.process.run([...NODE, held.cli, 'scope', '--no-open'], { cwd: held.root, timeoutMs: 120_000 })
341 url = /^Scope: (http\S+)$/m.exec(ran.stdout)?.[1]
342 if (url === undefined) why = ran.stderr.trim() || `exit ${ran.exitCode}`
343 }
344 if (url === undefined) {
345 $.ui.toast(`Coherence: Scope did not open (${why})`)
346 return
347 }
348 await $.process.run(['open', url])
349 $.ui.toast('Coherence: Scope opened in your browser')
350}
351
352export const register: Register = on => {
353 journalPane(on)
354
355 on('session.start', async ($, e, next) => {
356 const started = await next(e)
357 await ready($)
358 await $.command.register({ name: 'scope', description: "Open Coherence's live Scope reading in the browser", immediate: true })
359 await $.command.register(JOURNAL_COMMAND)
360 await $.tool.register(JOURNAL_TOOL)
361 $.clock.every(3000, () => void refreshStatus($))
362 return started
363 })
364
365 // A hook of these that fails leaves the event to the settings hooks beneath, and says so where the agent reads it: never a silent skip.
366 on('classic.SessionStart', ($, e, next) => (held.aside.has('SessionStart') ? next(e) : classic($, 'SessionStart', e, next(e)))).catch(async ($, e, next) => missed(await next(e), 'SessionStart', next.error))
367 on('classic.SubagentStart', ($, e, next) => (held.aside.has('SubagentStart') ? next(e) : classic($, 'SubagentStart', e, next(e)))).catch(async ($, e, next) => missed(await next(e), 'SubagentStart', next.error))
368 on('classic.UserPromptSubmit', ($, e, next) => (held.aside.has('UserPromptSubmit') ? next(e) : classic($, 'UserPromptSubmit', e, next(e)))).catch(async ($, e, next) => missed(await next(e), 'UserPromptSubmit', next.error))
369 on('classic.PostToolUse', ($, e, next) => (held.aside.has('PostToolUse') ? next(e) : classic($, 'PostToolUse', e, next(e)))).catch(async ($, e, next) => missed(await next(e), 'PostToolUse', next.error))
370 on('classic.Stop', ($, e, next) => (held.aside.has('Stop') ? next(e) : classic($, 'Stop', e, next(e)))).catch(async ($, e, next) => missed(await next(e), 'Stop', next.error))
371 on('classic.SubagentStop', ($, e, next) => (held.aside.has('SubagentStop') ? next(e) : classic($, 'SubagentStop', e, next(e)))).catch(async ($, e, next) => missed(await next(e), 'SubagentStop', next.error))
372
373 on('classic.PreToolUse', async ($, e, next) => {
374 if (held.aside.has('PreToolUse') || !PRACTICE_TOOLS.has(String(e.tool))) return next(e)
375 const [answer, below] = await Promise.all([forward($, 'PreToolUse', await preToolUse($, e)), next(e)])
376 const { classic: mine, user, debug } = translate('PreToolUse', answer)
377 tell($, 'PreToolUse', user, debug)
378 // A refusal stands over whatever beneath decided; Coherence never answers allow or ask.
379 if (mine.deny !== undefined) return { deny: mine.deny, ...(below.additionalContext === undefined ? {} : { additionalContext: below.additionalContext }) }
380 if (mine.additionalContext === undefined) return below
381 return { ...below, additionalContext: [...(below.additionalContext ?? []), ...mine.additionalContext] }
382 }).catch(async ($, e, next) => missed(await next(e), 'PreToolUse', next.error))
383
384 // The stop's digest, which a settings hook shows the user as its systemMessage, shows beneath the answer instead.
385 on('session.end', ($, e, next) => {
386 held.ending = true
387 return next(e)
388 })
389
390 on('turn.complete', async ($, e, next) => {
391 const answered = await next(e)
392 const text = held.digest
393 if (e.agentId !== undefined || text === undefined) return answered
394 held.digest = undefined
395 return { ...answered, text }
396 })
397
398 // An edit's verdict on the tool call's own row, where the user alone reads it.
399 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
400 const verdict = await $.state.get({ plugin: 'coherence', key: 'verdict', id: e.props.tool_use_id })
401 if (verdict.value === undefined) return next(e)
402 const row = await next(e)
403 const { Box, Text } = $.ui.resolve(e)
404 return (
405 <Box flexDirection="column">
406 {row}
407 <Text color={verdict.value.isFailed ? 'error' : 'subtle'}> ⎿ coherence {verdict.value.text}</Text>
408 </Box>
409 )
410 })
411
412 // The band above the prompt, for the person: Scope opens as /scope does, Journal as /journal does; it yields to a survey.
413 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
414 if (e.props.hasSurvey) return next(e)
415 const { Box, Button, Text } = $.ui.resolve(e)
416 // A row of its own, set off from the transcript above: the tool's name dim, its button drawn as one right beside it, whatever the width.
417 return (
418 <Box marginTop={1} gap={2}>
419 <Text dimColor>coherence</Text>
420 <Button key="scope" label="Scope ↗" variant="primary" onPress={() => void openScope($)} />
421 <Button key="journal" label="Journal" onPress={() => void $.ui.open({ id: JOURNAL_PANE, title: 'Journal' })} />
422 </Box>
423 )
424 })
425
426 on('command.run', { command: 'scope' }, async $ => {
427 await openScope($)
428 return {}
429 })
430}
431hooks/writers.ts 3 lines1// Generated from src/lifecycle/host-tools.ts (WRITERS.claude); src/lifecycle/mod-install.test.ts holds it equal. Do not edit by hand.
2export const WRITER_TOOLS: readonly string[] = ["Bash","Edit","Write","MultiEdit","NotebookEdit","PowerShell","Monitor"]
3hooks/answer.ts 94 lines1// What a Coherence hook answer means to Claude Code's classic chain, kept free
2// of `$` so the mapping reads alone: the per-event hook's stdout, stderr and
3// exit code in, the classic result and what only the user reads out.
4
5/** The events the mod answers through the warm process, as the settings hooks name them: every one `coherence hooks install --host claude` wires. */
6export const EVENTS = ['SessionStart', 'SubagentStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop'] as const
7export type CoherenceEvent = (typeof EVENTS)[number]
8
9/** An edit's chokepoint check as hook-serve says it (EditVerdict in src/lifecycle/hook.ts). */
10export type Verdict = {
11 checked: number
12 failed: { invariant: string; bypass?: string }[]
13 unchecked: number
14 reason?: string
15}
16
17/** One event's answer, from the warm process or from `coherence hook <event>`. */
18export type HookAnswer = { stdout: string; stderr: string; exit: number; verdict?: Verdict }
19
20/** The part of a classic result Coherence ever sets: context, a block, a deny. Never an allow. */
21export type Classic = { additionalContext?: string[]; block?: string; deny?: string }
22
23/** The exit code both hosts read as a refusal, the reason on stderr. */
24const REFUSE_EXIT = 2
25/** The exit code of an event outside the project the hook serves: nothing told, nothing held. */
26const OUTSIDE_ROOT_EXIT = 78
27
28/**
29 * The classic result an answer comes to, and the line only the user reads:
30 * a `systemMessage` (a stop's digest), which a classic result cannot carry,
31 * or a hook that failed as a settings hook's failure would be shown.
32 */
33export function translate(event: CoherenceEvent, answer: HookAnswer): { classic: Classic; user?: string; debug?: string } {
34 const stderr = answer.stderr.trim()
35 if (answer.exit === REFUSE_EXIT) {
36 const reason = stderr === '' ? `Coherence refused at ${event} without a reason` : stderr
37 return { classic: event === 'PreToolUse' ? { deny: reason } : { block: reason } }
38 }
39 if (answer.exit === OUTSIDE_ROOT_EXIT) return { classic: {}, debug: stderr }
40 if (answer.exit !== 0) return { classic: {}, user: `Coherence ${event} hook failed (exit ${answer.exit})${stderr === '' ? '' : `: ${stderr}`}` }
41 const out = answer.stdout.trim()
42 if (out === '') return { classic: {} }
43 let parsed: { hookSpecificOutput?: { additionalContext?: unknown }; decision?: unknown; reason?: unknown; systemMessage?: unknown }
44 try {
45 parsed = JSON.parse(out) as typeof parsed
46 } catch {
47 // A settings hook's plain stdout is context at a session start and a prompt, and a transcript line elsewhere.
48 return event === 'SessionStart' || event === 'UserPromptSubmit' ? { classic: { additionalContext: [out] } } : { classic: {}, user: out }
49 }
50 const classic: Classic = {}
51 const context = parsed.hookSpecificOutput?.additionalContext
52 if (typeof context === 'string' && context !== '') classic.additionalContext = [context]
53 if (parsed.decision === 'block') classic.block = typeof parsed.reason === 'string' && parsed.reason !== '' ? parsed.reason : `Coherence refused ${event} without a reason`
54 const user = typeof parsed.systemMessage === 'string' && parsed.systemMessage !== '' ? parsed.systemMessage : undefined
55 return user === undefined ? { classic } : { classic, user }
56}
57
58/** The row's one line for an edit's check, or undefined when the edit touched no chokepoint invariant. */
59export function verdictLine(verdict: Verdict | undefined): { text: string; isFailed: boolean } | undefined {
60 if (verdict === undefined) return undefined
61 const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? '' : 's'}`
62 if (verdict.reason !== undefined) return { text: `○ ${plural(verdict.unchecked, 'invariant')} not checked: ${verdict.reason}`, isFailed: false }
63 const first = verdict.failed[0]
64 if (first !== undefined) {
65 const more = verdict.failed.length > 1 ? ` and ${verdict.failed.length - 1} more` : ''
66 return { text: `✗ bypass: ${first.invariant}${first.bypass === undefined ? '' : ` (${first.bypass})`}${more}`, isFailed: true }
67 }
68 const unchecked = verdict.unchecked > 0 ? `, ${verdict.unchecked} not checked` : ''
69 if (verdict.checked > 0) return { text: `✓ ${plural(verdict.checked, 'invariant')}${unchecked}`, isFailed: false }
70 if (verdict.unchecked > 0) return { text: `○ ${plural(verdict.unchecked, 'invariant')} not checked`, isFailed: false }
71 return undefined
72}
73
74/**
75 * Whether the settings in force already run Coherence's own hook for this
76 * event: then the mod stands aside for it, since answering too would run
77 * every check and write every record twice.
78 */
79export function settingsRunCoherence(hooks: unknown, event: CoherenceEvent): boolean {
80 if (typeof hooks !== 'object' || hooks === null) return false
81 const groups = (hooks as Record<string, unknown>)[event]
82 if (!Array.isArray(groups)) return false
83 const ours = new RegExp(`\\bhook\\s+${event}\\b`)
84 return groups.some((group) => {
85 const inner = (group as { hooks?: unknown }).hooks
86 return Array.isArray(inner) && inner.some((h) => typeof (h as { command?: unknown }).command === 'string' && /coherence/i.test((h as { command: string }).command) && ours.test((h as { command: string }).command))
87 })
88}
89
90/** One request line of the warm process's protocol (hook-serve.ts). */
91export function request(id: number, event: CoherenceEvent, input: unknown): string {
92 return JSON.stringify({ id, op: 'hook', event, input })
93}
94hooks/journal-pane.tsx 429 lines1// The Journal pane: the project's Coherence journal told as it happens and
2// after the fact. Each record reads in the lexicon's verb (decided, found,
3// conjectured, needs you…) and its own words, which fold; under it, always,
4// a small tree of the files it pinged, so the person feels where the work went
5// without reading it.
6//
7// Live, its default, it streams the latest records across every session in
8// the project's journal folder and in every git worktree of the repository,
9// each under its agent and its session's start, so a background agent's
10// decisions land beside the main session's as they are written; the store
11// files are read straight (journal.ts's LiveJournal), a look every ~2 s while
12// the pane is open and after each Bash call. One session's journal
13// (`/journal <session>`) is read through the project's own Coherence
14// (`coherence journal --session <id> --json`). The pings come from the hooks'
15// write trace and the branch's own commits, of the worktree the session ran in.
16//
17// The band's Journal button and `/journal [live|session]` open it; the agent
18// drives it through one tool, `mcp__coherence__journal`. At the end it follows
19// what lands, scrolled up it holds still and counts what landed below.
20
21import { atom, read, update } from 'claude-code'
22import type { EngineInterface as Engine, On, Timer } from 'claude-code'
23import type { Contact, JournalEntry, JournalShown } from '../types'
24import { LiveJournal, commitsOf, entriesOf, headlineOf, isRecordFile, sessionOf, sessionOfFile, sourceOf, verbOf, wordsOf, writesOf, type JournalIo, type Recorded } from './journal.ts'
25import { place } from './place.ts'
26import { treeOf } from './tree.ts'
27
28export const JOURNAL_PANE = 'coherence-journal'
29const NODE = ['node', '--disable-warning=ExperimentalWarning']
30/** How many of a journal's latest records the pane draws. */
31const RECORDS = 60
32/** The width of the grid's gutter: time, verb, labels. */
33const GUTTER = 20
34/** How often the open pane looks at the journal's files. */
35const TICK_MS = 2000
36
37const shownJournal = atom({ plugin: 'coherence', key: 'journal' } as const, null as JournalShown | null)
38const opened = atom({ plugin: 'coherence', key: 'journalOpened' } as const, [] as string[])
39const view = atom({ plugin: 'coherence', key: 'journalView' } as const, { following: true, unseen: 0 })
40
41/** The module's own: one session's journal files last read, by their sizes and times, so an unchanged journal is not read again. */
42const seen = { session: '', signature: '' }
43/** The module's own: the live journal, what it has read of each file, and the open pane's timer. */
44const held: { live: LiveJournal | undefined; timer: Timer | undefined; isLooking: boolean } = { live: undefined, timer: undefined, isLooking: false }
45
46/** The disk and git, through the engine. */
47function ioOf($: Engine): JournalIo {
48 return {
49 list: dir => $.fs.list(dir),
50 read: path => $.fs.read(path),
51 tail: async (path, fromByte) => (await $.process.run(['tail', '-c', `+${fromByte + 1}`, path], { cwd: place.root })).stdout,
52 exists: path => $.fs.exists(path),
53 run: async (argv, cwd) => {
54 const ran = await $.process.run(argv, { cwd, timeoutMs: 30_000 }).catch(() => undefined)
55 return ran?.exitCode === 0 ? ran.stdout : ''
56 },
57 now: () => $.clock.now(),
58 }
59}
60
61/** The live journal for this session's project, made on first use. */
62function liveJournal(): LiveJournal {
63 if (held.live?.root !== place.root) held.live = new LiveJournal(place.root)
64 return held.live
65}
66
67/** The journal sessions on disk, newest file first. */
68async function sessions($: Engine, root: string): Promise<{ name: string; size: number; mtimeMs: number }[]> {
69 const entries = await $.fs.list(`${root}/.coherence/journal`).catch(() => [])
70 return entries.filter(e => e.kind === 'file' && isRecordFile(e.name)).sort((a, b) => b.mtimeMs - a.mtimeMs)
71}
72
73/** The session a prefix names: this session's own when none is given and it has records, else the journal's latest. */
74async function chosen($: Engine, prefix: string): Promise<string | undefined> {
75 const files = await sessions($, place.root)
76 if (prefix !== '') return sessionOf(prefix, files.map(f => f.name))
77 const mine = await $.session.id()
78 return files.some(f => sessionOfFile(f.name) === mine) ? mine : sessionOf('', files.map(f => f.name))
79}
80
81/** A session's store files in the project's journal folder, as one signature of their names, sizes and times. */
82async function signatureOf($: Engine, session: string): Promise<string> {
83 const files = await sessions($, place.root)
84 return files
85 .filter(f => sessionOfFile(f.name) === session)
86 .map(f => `${f.name}:${f.size}:${f.mtimeMs}`)
87 .sort()
88 .join('|')
89}
90
91/** Reads one session's journal through Coherence and tells it; why not, when it cannot. */
92async function told($: Engine, session: string): Promise<JournalShown | string> {
93 const cli = place.cli
94 if (cli === undefined) return 'no Coherence command line'
95 const root = place.root
96 const ran = await $.process.run([...NODE, cli, 'journal', '--session', session, '--json'], { cwd: root, timeoutMs: 60_000 })
97 if (ran.exitCode !== 0) return ran.stderr.trim().split('\n').at(-1) ?? `exit ${ran.exitCode}`
98 const said = JSON.parse(ran.stdout) as { records?: Recorded[]; escalations?: unknown[] }
99 const records = said.records ?? []
100 const commits = records.map(r => r.commit).filter((c): c is string => typeof c === 'string')
101 const newest = [...records].sort((a, b) => b.at.localeCompare(a.at)).map(r => r.commit).find((c): c is string => typeof c === 'string')
102 // The branch's own first-parent line from the newest record's commit: a merge brings in other people's work, which no record here is about.
103 const log = newest === undefined ? '' : (await $.process.run(['git', 'log', '--first-parent', '--name-only', '--format=@%h', '-n', String(Math.min(2000, commits.length + 400)), newest], { cwd: root }).catch(() => undefined))?.stdout ?? ''
104 const trace = await $.fs.read(`${root}/.coherence/traces/${session}.jsonl`).catch(() => '')
105 return { session, records: entriesOf(records, commitsOf(log), writesOf(typeof trace === 'string' ? trace : '')), waiting: (said.escalations ?? []).length }
106}
107
108/** The live view: the latest records across every session and worktree, each told with its tree. */
109async function toldLive($: Engine): Promise<JournalShown> {
110 const io = ioOf($)
111 const live = liveJournal()
112 const latest = live.latest(RECORDS)
113 const records = await live.entries(io, latest)
114 const worktrees = (await live.worktrees(io)).length
115 return { session: 'live', records, waiting: live.waiting, live: { total: live.total, sessions: new Set(latest.map(r => r.session)).size, worktrees, damaged: live.damaged } }
116}
117
118/** The journal's headline as the title and the agent read it. */
119function headOf(shown: JournalShown): string {
120 const damaged = shown.live === undefined || shown.live.damaged === 0 ? '' : ` · ${shown.live.damaged} damaged line${shown.live.damaged === 1 ? '' : 's'} skipped`
121 return `${shown.live === undefined ? shown.session.slice(0, 8) : 'live'} · ${headlineOf(shown.records, shown.waiting)}${damaged}`
122}
123
124/** Whether the pane is open. */
125async function isOpen($: Engine): Promise<boolean> {
126 return (await $.ui.panes().catch(() => [])).some(p => p.id === JOURNAL_PANE)
127}
128
129/** The pane's title: the journal's headline, and what landed below while the person read further up. */
130async function retitle($: Engine): Promise<void> {
131 if (!(await isOpen($))) return
132 const shown = await read($, shownJournal)
133 const { unseen } = await read($, view)
134 await $.ui.open({ id: JOURNAL_PANE, title: `Journal${shown === null ? '' : ` · ${headOf(shown)}`}${unseen === 0 ? '' : ` · ↓ ${unseen} new below`}` }).catch(() => undefined)
135}
136
137/** Back to the journal's end: the window follows again and what landed below counts as seen. */
138async function toEnd($: Engine): Promise<void> {
139 await update($, view, () => ({ following: true, unseen: 0 }))
140 await $.ui.scroll({ in: JOURNAL_PANE, to: 'end' }).catch(() => undefined)
141 await retitle($)
142}
143
144/** While the pane is open, a look every TICK_MS; the look ends the timer once the pane is closed. */
145function startLooking($: Engine): void {
146 if (held.timer !== undefined) return
147 held.timer = $.clock.every(TICK_MS, () => void look($).catch(() => undefined))
148}
149
150/** Opens the pane on the live journal and says what it shows. */
151async function openLive($: Engine): Promise<string> {
152 await liveJournal().scan(ioOf($))
153 const shown = await toldLive($)
154 await update($, shownJournal, () => shown)
155 await $.ui.open({ id: JOURNAL_PANE, title: 'Journal' })
156 startLooking($)
157 await toEnd($)
158 const live = shown.live
159 return `The Journal pane streams the latest ${shown.records.length} of ${live?.total ?? 0} records, ${live?.sessions ?? 0} sessions across ${live?.worktrees ?? 1} worktrees: ${headOf(shown).replace(/^live · /, '')}.`
160}
161
162/** Opens the pane: live when no session is named (or `live` is), else on that session's journal; says what it shows. */
163async function openJournal($: Engine, prefix = ''): Promise<string> {
164 if (place.root === '') return 'Coherence is still starting; try again in a moment.'
165 if (prefix === '' || prefix === 'live') return openLive($)
166 const session = await chosen($, prefix)
167 if (session === undefined) return `No journal session starts ${prefix}.`
168 const shown = await told($, session)
169 if (typeof shown === 'string') return `The journal could not be read: ${shown}`
170 await update($, shownJournal, () => shown)
171 Object.assign(seen, { session, signature: await signatureOf($, session) })
172 await $.ui.open({ id: JOURNAL_PANE, title: 'Journal' })
173 startLooking($)
174 await toEnd($)
175 return `The Journal pane shows ${session}: ${shown.records.length} records, ${headlineOf(shown.records, shown.waiting)}.`
176}
177
178/** What lands: at the end the pane follows it, scrolled up it counts what landed below. */
179async function land($: Engine, before: JournalShown, next: JournalShown): Promise<void> {
180 const had = new Set(before.records.map(r => r.id))
181 const last = before.records.at(-1)?.at ?? ''
182 const landed = next.records.filter(r => !had.has(r.id) && r.at >= last).length
183 await update($, shownJournal, () => next)
184 if ((await read($, view)).following) await $.ui.scroll({ in: JOURNAL_PANE, to: 'end' }).catch(() => undefined)
185 else if (landed > 0) await update($, view, v => ({ ...v, unseen: v.unseen + landed }))
186 await retitle($)
187}
188
189/**
190 * One look while the pane is open. Live: list the worktrees' journal folders
191 * and read what moved, never the command line. One session's: its files'
192 * signature, and the command line only when it moved. A closed pane ends the timer.
193 */
194async function look($: Engine): Promise<void> {
195 if (held.isLooking) return
196 held.isLooking = true
197 try {
198 if (!(await isOpen($))) {
199 held.timer?.cancel()
200 held.timer = undefined
201 return
202 }
203 const shown = await read($, shownJournal)
204 if (shown === null) return
205 if (shown.live !== undefined) {
206 const live = liveJournal()
207 const damaged = live.damaged
208 const { landed } = await live.scan(ioOf($))
209 if (landed.length === 0 && live.damaged === damaged) return
210 await land($, shown, await toldLive($))
211 return
212 }
213 const signature = await signatureOf($, shown.session)
214 if (signature === '' || (seen.session === shown.session && seen.signature === signature)) return
215 Object.assign(seen, { session: shown.session, signature })
216 const next = await told($, shown.session)
217 if (typeof next !== 'string') await land($, shown, next)
218 } finally {
219 held.isLooking = false
220 }
221}
222
223type Asked = { session?: string; open?: string[]; fold?: string[]; scrollTo?: string }
224
225/** The agent's hand on the pane: which journal, which records to open or fold, where to scroll. */
226async function drive($: Engine, asked: Asked): Promise<string> {
227 const said: string[] = []
228 if (asked.session !== undefined || (await read($, shownJournal)) === null) said.push(await openJournal($, asked.session ?? ''))
229 const ids = (list: string[] | undefined): string[] => (list ?? []).flatMap(id => [`rec:${id}`, `tree:${id}`])
230 if ((asked.open ?? []).length > 0 || (asked.fold ?? []).length > 0) {
231 const folding = new Set(ids(asked.fold))
232 await update($, opened, now => [...new Set([...now.filter(x => !folding.has(x)), ...ids(asked.open)])])
233 said.push([...(asked.open?.length ? [`opened ${asked.open.join(', ')}`] : []), ...(asked.fold?.length ? [`folded ${asked.fold.join(', ')}`] : [])].join('; '))
234 }
235 if (asked.scrollTo !== undefined) {
236 const to = asked.scrollTo === 'end' || asked.scrollTo === 'start' ? asked.scrollTo : { key: `rec-${asked.scrollTo}` }
237 await update($, view, v => ({ ...v, following: asked.scrollTo === 'end' }))
238 const moved = await $.ui.scroll({ in: JOURNAL_PANE, to, block: 'start' }).catch((error: unknown) => ({ deny: error instanceof Error ? error.message : String(error) }))
239 said.push(moved.deny === undefined ? `scrolled to ${asked.scrollTo}` : `could not scroll to ${asked.scrollTo}: ${moved.deny}`)
240 }
241 return said.length === 0 ? 'Nothing asked of the pane.' : `${said.map(s => s.replace(/\.$/, '')).join('. ')}.`
242}
243
244/** A time of day, 24-hour. */
245function clockOf(iso: string): string {
246 const d = new Date(iso)
247 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
248}
249
250/** The /journal command, as the mod registers it at the session's start. */
251export const JOURNAL_COMMAND = { name: 'journal', description: "Open the Journal pane: the latest records live across every session and worktree, or one session's journal by its id's start (/journal live goes back)" }
252
253/** The agent's hand on the pane, as the mod registers it at the session's start; served here as mcp__coherence__journal. */
254export const JOURNAL_TOOL = {
255 name: 'journal',
256 description:
257 "Controls the person's Journal pane, which tells them the Coherence journal: each record (decided, found, conjectured, needs you…) under its agent and session, with a small tree of the files it touched. By default it streams the latest records live across every session and git worktree of the repository. session: an id's start shows that one session's journal; 'live' goes back to the live view; left out, the pane keeps what it shows (live when nothing is open). open / fold: record ids (d-…, df-…, c-…) whose words to open or fold. scrollTo: 'end', 'start' or a record id. Use it to show the person the record you are talking about.",
258 inputSchema: {
259 type: 'object',
260 properties: {
261 session: { type: 'string', description: "a session id or its start, or 'live' for the latest records across every session and worktree" },
262 open: { type: 'array', items: { type: 'string' } },
263 fold: { type: 'array', items: { type: 'string' } },
264 scrollTo: { type: 'string', description: "'end', 'start' or a record id" },
265 },
266 additionalProperties: false,
267 },
268 isDeferred: false as const,
269}
270
271/** The pane's hooks: /journal, the agent's tool, the pane's opening, the looks, the person's scrolling, and the drawing. */
272export function journalPane(on: On): void {
273 on('command.run', { command: 'journal' }, async ($, e) => ({ text: await openJournal($, e.args.trim()) }))
274
275 on('tool.call', { tool: /^mcp__coherence__journal$/ }, async ($, e) => ({ result: await drive($, e as unknown as Asked) }))
276
277 // The band's Journal button: its own $.ui.open never reaches this plugin's ui.open hook, so the press itself loads the journal.
278 on('ui.press', { plugin: 'coherence', component: 'AbovePrompt', element: 'journal' }, async ($, e) => {
279 await openJournal($)
280 return { element: e.element }
281 })
282
283 // Opened by another plugin or the host: it streams the live journal when nothing is shown, and looks again either way.
284 on('ui.open', { id: JOURNAL_PANE }, async ($, e, next) => {
285 const answer = await next(e)
286 if ((await read($, shownJournal)) === null) void openJournal($).catch(() => undefined)
287 else startLooking($)
288 return answer
289 })
290
291 // A record is written through the command line, so a Bash call is when the journal may well have grown: a look then, besides the timer's.
292 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
293 const ran = await next(e)
294 void look($).catch(() => undefined)
295 return ran
296 })
297
298 // The person's own scrolling decides whether the pane follows: at the end it does, and what landed below is seen.
299 on('ui.scroll', { component: 'Pane', requestId: JOURNAL_PANE }, async ($, e, next) => {
300 const moved = await next(e)
301 if (e.origin.kind === 'person') {
302 const atEnd = e.offset + e.bodyRows >= e.contentRows - 1
303 if (atEnd !== (await read($, view)).following) {
304 await update($, view, v => ({ following: atEnd, unseen: atEnd ? 0 : v.unseen }))
305 if (atEnd) await retitle($)
306 }
307 }
308 return moved
309 })
310
311 on('ui.render', { component: 'Pane', requestId: JOURNAL_PANE }, async ($, e) => {
312 const { Box, Text, Button } = $.ui.resolve(e)
313 const shown = await read($, shownJournal)
314 const unfolded = new Set(await read($, opened))
315 const window = await read($, view)
316 const toggle = (id: string) => () => void update($, opened, now => (now.includes(id) ? now.filter(x => x !== id) : [...now, id]))
317 const contentWidth = Math.max(20, (e.props.bodyColumns ?? 80) - GUTTER - 1)
318
319 // A reload starts the module over with the pane still drawn: its drawing starts the looks again.
320 if (shown !== null) startLooking($)
321 if (shown === null) return <Text dimColor>No journal is open. /journal streams the latest records live, or one session's by its id's start.</Text>
322
323 // The grid: a gutter of fixed width and a content column that wraps inside itself.
324 const row = (left: unknown, content: unknown) => (
325 <Box flexDirection="row">
326 <Box width={GUTTER} flexShrink={0}>
327 {left as never}
328 </Box>
329 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
330 {content as never}
331 </Box>
332 </Box>
333 )
334 const cut = (text: string, width: number): string => (text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`)
335
336 // A record's ping: dim guides, folders in the text's own tone, files dim, a changed file bright; its folds open on press.
337 const tree = (contacts: readonly Contact[], id: string) => {
338 const isOpen = unfolded.has(`tree:${id}`)
339 const drawn = treeOf(contacts, isOpen ? Number.POSITIVE_INFINITY : 8)
340 const canFold = isOpen && treeOf(contacts, 8).length < drawn.length
341 return (
342 <Box flexDirection="column">
343 {drawn.map((line, i) =>
344 line.kind === 'more' ? (
345 <Box>
346 <Text dimColor>{line.guide}</Text>
347 <Button key={`fold-${id}-${i}`} label={line.text} plain dimColor onPress={toggle(`tree:${id}`)} />
348 </Box>
349 ) : (
350 <Text wrap="truncate-end">
351 <Text dimColor>{line.guide}</Text>
352 <Text dimColor={line.kind !== 'dir' && !line.isEdited} bold={line.isEdited}>{`${line.text}${line.isEdited ? ' ✎' : ''}`}</Text>
353 </Text>
354 ),
355 )}
356 {canFold && <Button key={`fewer-${id}`} label="fewer" plain dimColor onPress={toggle(`tree:${id}`)} />}
357 </Box>
358 )
359 }
360
361 // A record pings: its verb and words fold, its tree stays, so where the work went is always felt.
362 const record = (r: JournalEntry, time: string, isNewAgent: boolean, isNewDay: boolean) => {
363 const { head, lines } = wordsOf(r)
364 const isOpen = unfolded.has(`rec:${r.id}`)
365 const canOpen = lines.length > 0 || head.length > contentWidth
366 const color = r.kind === 'escalation' || r.kind === 'unable' ? 'warning' : r.kind === 'defect' ? 'error' : r.kind === 'decision' ? 'claude' : undefined
367 const verb = canOpen ? (
368 <Button key={`rec-${r.id}-open`} plain onPress={toggle(`rec:${r.id}`)}>
369 <Text dimColor>{isOpen ? '▾ ' : '▸ '}</Text>
370 <Text bold color={color}>
371 {verbOf(r.kind)}
372 </Text>
373 </Button>
374 ) : (
375 <Text bold={r.kind !== 'enactment'} dimColor={r.kind === 'enactment'} color={color}>{` ${verbOf(r.kind)}`}</Text>
376 )
377 return (
378 <Box key={`rec-${r.id}`} flexDirection="column" marginTop={r.kind === 'enactment' ? 0 : 1}>
379 {isNewDay && <Text dimColor>{`${new Date(r.at).toDateString()}\n`}</Text>}
380 {isNewAgent && (
381 <Text>
382 <Text bold>{r.agent === '' ? 'unnamed agent' : r.agent}</Text>
383 {r.session !== undefined && <Text dimColor>{` · ${r.session.slice(0, 8)}`}</Text>}
384 </Text>
385 )}
386 {row(
387 <Box flexDirection="row">
388 <Text dimColor>{time.padEnd(7)}</Text>
389 {verb}
390 </Box>,
391 isOpen ? <Text wrap="wrap">{head}</Text> : <Text dimColor={r.kind === 'enactment'}>{cut(head, contentWidth)}</Text>,
392 )}
393 {isOpen && lines.map(l => row(<Text dimColor>{`${''.padEnd(9)}${l.label}`}</Text>, <Text wrap="wrap" dimColor={l.label !== 'you said'}>{l.text}</Text>))}
394 {r.pings.length > 0 && row(<Text dimColor>{`${''.padEnd(9)}${sourceOf(r.from)}`}</Text>, tree(r.pings, r.id))}
395 </Box>
396 )
397 }
398
399 const records = shown.records.slice(-RECORDS)
400 const day = (iso: string): string => new Date(iso).toDateString()
401 return (
402 <Box flexDirection="column">
403 <Text wrap="wrap">
404 <Text bold>{shown.live === undefined ? `journal of ${shown.session.slice(0, 8)}` : 'live journal'}</Text>
405 <Text dimColor>{` · ${headlineOf(shown.records, shown.waiting)}`}</Text>
406 </Text>
407 {shown.live !== undefined && (
408 <Text dimColor wrap="wrap">{`latest ${shown.records.length} of ${shown.live.total} records · ${shown.live.sessions} sessions · ${shown.live.worktrees} worktrees${shown.live.damaged === 0 ? '' : ` · ${shown.live.damaged} damaged line${shown.live.damaged === 1 ? '' : 's'} skipped`}`}</Text>
409 )}
410 {shown.live === undefined && shown.records.length > records.length && <Text dimColor>{`${shown.records.length - records.length} earlier records`}</Text>}
411 {records.map((r, i) => {
412 const before = records[i - 1]
413 // A cluster of records in one minute shows its time once.
414 const time = before !== undefined && clockOf(before.at) === clockOf(r.at) && day(before.at) === day(r.at) ? '' : clockOf(r.at)
415 return record(r, time, before === undefined || before.agent !== r.agent || before.session !== r.session, before === undefined || day(before.at) !== day(r.at))
416 })}
417 {/* Scrolled up with news below: a bar on the window's own bottom row, painted over the journal, that jumps to the end. */}
418 {!window.following && window.unseen > 0 && e.props.scroll !== undefined && (
419 <Box position="absolute" top={e.props.scroll.offset + e.props.scroll.bodyRows - 1} left={0} width={e.props.bodyColumns ?? 80} backgroundColor="claude">
420 <Button key="to-end" plain onPress={() => void toEnd($)}>
421 <Text color="inverseText">{` ↓ ${window.unseen} new below · jump to latest `}</Text>
422 </Button>
423 </Box>
424 )}
425 </Box>
426 )
427 })
428}
429hooks/place.ts 11 lines1// Where Coherence is for this session, as the mod's setup found it: written by
2// register.tsx once it is set up, read by the Journal pane. Data only; the
3// engine interface never crosses a module.
4
5export const place = {
6 /** The session's project root; '' until the mod is set up. */
7 root: '',
8 /** The Coherence command line found for the project, when there is one. */
9 cli: undefined as string | undefined,
10}
11hooks/journal.ts 467 lines1// A session's journal as the Journal pane tells it: each record in the
2// lexicon's verb and its own words, and the files it pinged, with where the
3// ping came from. Pure, so the tests hold it without an engine.
4//
5// A record's files come, in order, from what it names itself (a defect's
6// files, an experiment's context), from what the session wrote since its
7// record before (the hooks' write trace, else the branch's own commits between
8// the two records' commits), and, when it has neither, from what it cites.
9
10import type { Contact, JournalEntry } from '../types'
11
12/** A record as `coherence journal --json` prints it: the fields the pane reads. */
13export type Recorded = Record<string, unknown> & { id: string; kind: string; at: string; session: string; agent: string; commit?: string | null; cites?: string[]; files?: string[]; context?: string[] }
14
15/** One commit of the branch's own first-parent line, newest first, and the files it changed. */
16export type Committed = { commit: string; files: string[] }
17
18/** One write line of the hooks' trace. */
19export type Written = { at: string; files: string[] }
20
21const VERBS: Record<string, string> = {
22 decision: 'decided',
23 defect: 'found',
24 resolution: 'resolved',
25 dismissal: 'dismissed',
26 retraction: 'retracted',
27 conjecture: 'conjectured',
28 experiment: 'experiment',
29 close: 'closed',
30 unable: 'unable',
31 escalation: 'needs you',
32 acknowledgement: 'acknowledged',
33 enactment: 'enacted',
34}
35
36/** The verb a record kind reads as; an escalation says it needs the person. */
37export function verbOf(kind: string): string {
38 return VERBS[kind] ?? kind
39}
40
41/** A record's own words: its headline, then the lines under it (what it turned down and why, the owner's words, its candidates). */
42export function wordsOf(r: JournalEntry): { head: string; lines: { label: string; text: string }[] } {
43 const lines: { label: string; text: string }[] = []
44 const add = (label: string, text: string | undefined): void => {
45 if (text !== undefined && text.trim() !== '') lines.push({ label, text })
46 }
47 let head = ''
48 switch (r.kind) {
49 case 'decision':
50 head = r.chose ?? ''
51 add('over', r.over?.join('; '))
52 add('because', r.because)
53 break
54 case 'defect':
55 head = r.what ?? ''
56 add('evidence', r.evidence)
57 break
58 case 'conjecture':
59 head = r.observation ?? ''
60 add('could be', r.couldBe?.join(' · '))
61 add('told apart by', r.discriminatedBy)
62 break
63 case 'experiment':
64 head = r.expectation ?? ''
65 break
66 case 'close':
67 head = `${r.outcome ?? 'closed'}${r.of === undefined ? '' : ` · ${r.of}`}`
68 break
69 case 'unable':
70 case 'escalation':
71 head = r.what ?? ''
72 add('because', r.because)
73 break
74 case 'enactment':
75 head = r.practice ?? ''
76 break
77 default:
78 head = r.because ?? r.what ?? ''
79 add('guard', r.guard)
80 }
81 add('you said', r.human)
82 return { head, lines }
83}
84
85/** Where a record's files came from, as one word for the gutter beside its tree. */
86export function sourceOf(from: readonly string[]): string {
87 if (from.includes('written') || from.includes('committed')) return 'changed'
88 if (from.includes('named')) return 'named'
89 if (from.includes('cited')) return 'cited'
90 return ''
91}
92
93/** The journal's headline: what the session recorded, and how many escalations still wait on the person. */
94export function headlineOf(records: readonly JournalEntry[], waiting: number): string {
95 const count = (kind: string): number => records.filter(r => r.kind === kind).length
96 const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? '' : 's'}`
97 return [
98 plural(count('decision'), 'decision'),
99 plural(count('defect'), 'defect'),
100 ...(count('conjecture') > 0 ? [plural(count('conjecture'), 'conjecture')] : []),
101 ...(count('experiment') > 0 ? [plural(count('experiment'), 'experiment')] : []),
102 waiting > 0 ? `${waiting} waiting on you` : 'nothing waiting on you',
103 ].join(' · ')
104}
105
106/** A file of the project's own, not Coherence's records or the host's. */
107const isProject = (f: string): boolean => f !== '' && !f.startsWith('.coherence/') && !f.startsWith('.claude/')
108
109/** The commits of `git log --first-parent --name-only --format=@%h`, newest first. */
110export function commitsOf(log: string): Committed[] {
111 const out: Committed[] = []
112 for (const line of log.split('\n')) {
113 if (line.startsWith('@')) out.push({ commit: line.slice(1).trim(), files: [] })
114 else if (line.trim() !== '') out.at(-1)?.files.push(line.trim())
115 }
116 return out
117}
118
119/** The write lines of the hooks' trace for a session. */
120export function writesOf(trace: string): Written[] {
121 return trace
122 .split('\n')
123 .filter(l => l.trim() !== '')
124 .flatMap(l => {
125 try {
126 const line = JSON.parse(l) as { kind?: string; at?: string; files?: string[]; file?: string }
127 return line.kind === 'write' && typeof line.at === 'string' ? [{ at: line.at, files: line.files ?? (line.file === undefined ? [] : [line.file]) }] : []
128 } catch {
129 return []
130 }
131 })
132}
133
134/** Whether two commit names are one commit: a short hash and a longer one agree on what they share. */
135const same = (a: string, b: string): boolean => a.startsWith(b) || b.startsWith(a)
136
137/** The files the branch's own commits changed after `from` and up to `to`, on the first-parent line; none when either is off it. */
138function changedBetween(chain: readonly Committed[], from: string | null | undefined, to: string | null | undefined): string[] {
139 if (from == null || to == null || same(from, to)) return []
140 const at = chain.findIndex(c => same(c.commit, to))
141 const before = chain.findIndex(c => same(c.commit, from))
142 if (at === -1 || before === -1 || at > before) return []
143 return chain.slice(at, before).flatMap(c => c.files)
144}
145
146/** The records in order, each told: its fields that say something, and the files it pinged with where they came from. */
147export function entriesOf(records: readonly Recorded[], chain: readonly Committed[], writes: readonly Written[]): JournalEntry[] {
148 const sorted = [...records].filter(r => typeof r.at === 'string').sort((a, b) => a.at.localeCompare(b.at))
149 const byId = new Map(sorted.map(r => [r.id, r]))
150 const namedBy = (r: Recorded): string[] => [...(Array.isArray(r.files) ? r.files : []), ...(Array.isArray(r.context) ? r.context : [])].filter(isProject)
151 return sorted.map((r, i) => {
152 const before = sorted[i - 1]
153 const named = [...new Set(namedBy(r))]
154 const traced = writes.filter(w => (before === undefined || w.at > before.at) && w.at <= r.at).flatMap(w => w.files).filter(isProject)
155 const written = [...new Set(traced.length > 0 ? traced : changedBetween(chain, before?.commit, r.commit).filter(isProject))]
156 const cited = [...new Set((r.cites ?? []).flatMap(id => {
157 const c = byId.get(id)
158 return c === undefined ? [] : namedBy(c)
159 }))]
160 const pings: Contact[] = named.map(path => ({ path, kind: 'read' as const }))
161 for (const path of written) if (!named.includes(path)) pings.push({ path, kind: 'edit' })
162 const from = named.length > 0 || written.length > 0 ? [...(named.length > 0 ? ['named'] : []), ...(written.length > 0 ? [traced.length > 0 ? 'written' : 'committed'] : [])] : cited.length > 0 ? ['cited'] : []
163 if (pings.length === 0) for (const path of cited) pings.push({ path, kind: 'read' })
164 const text = (k: string): string | undefined => (typeof r[k] === 'string' ? (r[k] as string) : undefined)
165 const list = (k: string): string[] | undefined => (Array.isArray(r[k]) ? (r[k] as string[]) : undefined)
166 const entry: JournalEntry = { id: r.id, kind: r.kind, at: r.at, agent: r.agent, session: r.session, cites: r.cites ?? [], pings: pings.slice(0, 40), from }
167 for (const k of ['chose', 'because', 'human', 'what', 'evidence', 'observation', 'discriminatedBy', 'expectation', 'outcome', 'of', 'guard', 'practice'] as const) {
168 const v = text(k)
169 if (v !== undefined) entry[k] = v
170 }
171 const over = list('over')
172 if (over !== undefined) entry.over = over
173 const couldBe = list('couldBe')
174 if (couldBe !== undefined) entry.couldBe = couldBe
175 return entry
176 })
177}
178
179/** The session a store file's name starts with: `<session>.jsonl`, or a sealed shard's `<session>.<token>.jsonl`. */
180export function sessionOfFile(name: string): string {
181 return name.split('.')[0] ?? name
182}
183
184/** The journal session a prefix names among the journal's files, or undefined when none does. */
185export function sessionOf(prefix: string, names: readonly string[]): string | undefined {
186 return names.filter(isRecordFile).map(sessionOfFile).find(n => n.startsWith(prefix))
187}
188
189// The live journal: the latest records across every session in the project's
190// journal folder and in every git worktree of the same repository, kept up by
191// listings alone. A tick lists each folder (one call that carries each file's
192// size and time), reads only a file whose size or time moved, and parses only
193// the bytes past what it already took from that file. A record's session is
194// its own `session` field, never its file's name, so a sealed shard
195// (`<session>.<token>.jsonl`) or a packed file (records on one line, each
196// ended by U+001E) reads the same. A piece that is no record is counted,
197// never dropped unsaid.
198
199/** The extensions a record store's file carries: a packed store's joins this list. */
200export const RECORD_FILE_EXTENSIONS: readonly string[] = ['.jsonl', '.jsonseq']
201/** What ends a record: a line, or the packed form's record separator. */
202const RECORD_END = /[\n\u001e]/
203/** The most one `$.fs.read` copies; a file past it is read from its offset through `tail`. */
204export const READ_LIMIT = 4 * 1024 * 1024
205/** How long the repository's worktree list holds before it is asked again. */
206export const WORKTREES_HOLD_MS = 30_000
207
208export function isRecordFile(name: string): boolean {
209 return RECORD_FILE_EXTENSIONS.some(ext => name.endsWith(ext))
210}
211
212/** What the live journal reaches the disk and git through: the engine's `$` in the pane, Node's own in a bench. */
213export type JournalIo = {
214 list: (dir: string) => Promise<readonly { name: string; kind: string; size: number; mtimeMs: number }[]>
215 read: (path: string) => Promise<string>
216 /** A file's text from a byte offset on, for one past `READ_LIMIT`. */
217 tail: (path: string, fromByte: number) => Promise<string>
218 exists: (path: string) => Promise<boolean>
219 /** A command's stdout; '' when it fails. */
220 run: (argv: string[], cwd: string) => Promise<string>
221 now: () => Promise<number>
222}
223
224/** The UTF-8 bytes of one UTF-16 unit at `i`, and how many units it spans. */
225function unitBytes(text: string, i: number): [number, number] {
226 const c = text.charCodeAt(i)
227 if (c < 0x80) return [1, 1]
228 if (c < 0x800) return [2, 1]
229 if (c >= 0xd800 && c < 0xdc00) return [4, 2]
230 return [3, 1]
231}
232
233/** UTF-8 bytes of a string, as a file's size counts them. */
234export function utf8Length(text: string): number {
235 let n = 0
236 for (let i = 0; i < text.length; ) {
237 const [bytes, units] = unitBytes(text, i)
238 n += bytes
239 i += units
240 }
241 return n
242}
243
244/** The string offset at which the first `bytes` UTF-8 bytes of `text` end. */
245function charsAt(text: string, bytes: number): number {
246 let n = 0
247 let i = 0
248 while (i < text.length && n < bytes) {
249 const [b, units] = unitBytes(text, i)
250 n += b
251 i += units
252 }
253 return i
254}
255
256function isRecord(v: unknown): v is Recorded {
257 if (typeof v !== 'object' || v === null) return false
258 const r = v as Record<string, unknown>
259 return typeof r.id === 'string' && typeof r.kind === 'string' && typeof r.at === 'string' && typeof r.session === 'string'
260}
261
262/**
263 * The records in a stretch of a store file, split on lines and on the packed
264 * form's separator. The piece after the last separator is a record still
265 * being written unless it parses, so `consumed` stops before it; every other
266 * piece that is no record is `damaged`.
267 */
268export function recordsIn(text: string): { records: Recorded[]; damaged: string[]; consumed: number } {
269 const records: Recorded[] = []
270 const damaged: string[] = []
271 const pieces = text.split(RECORD_END)
272 const last = pieces.pop() ?? ''
273 const take = (piece: string): boolean => {
274 try {
275 const v: unknown = JSON.parse(piece)
276 if (!isRecord(v)) return false
277 records.push({ ...v, agent: typeof v.agent === 'string' ? v.agent : '' })
278 return true
279 } catch {
280 return false
281 }
282 }
283 for (const piece of pieces) if (piece.trim() !== '' && !take(piece)) damaged.push(piece)
284 const consumed = last.trim() === '' || take(last) ? text.length : text.length - last.length
285 return { records, damaged, consumed }
286}
287
288/** The worktree paths of `git worktree list --porcelain`. */
289export function worktreesOf(porcelain: string): string[] {
290 return porcelain
291 .split('\n')
292 .filter(l => l.startsWith('worktree '))
293 .map(l => l.slice('worktree '.length).trim())
294}
295
296/** How many escalations no acknowledgement points at. */
297function openEscalations(records: readonly Recorded[]): number {
298 const acknowledged = new Set(records.filter(r => r.kind === 'acknowledgement').map(r => r.of))
299 return records.filter(r => r.kind === 'escalation' && !acknowledged.has(r.id)).length
300}
301
302/** What one look did: the records that landed, and how many file contents it read. */
303export type Scanned = { landed: string[]; read: number }
304
305/** The live journal across the repository's worktrees: what it has taken of each store file, and every record it has seen, once each. */
306export class LiveJournal {
307 /** Per store file: the size and time it had when read, and how many of its bytes are taken. */
308 private readonly files = new Map<string, { size: number; mtimeMs: number; bytes: number }>()
309 /** `<name>\0<size>` of each copy read whole: another worktree's copy of the same name and size holds the same records. */
310 private readonly readWhole = new Set<string>()
311 /** Per file name, the worktrees holding a copy, and its size there. */
312 private readonly copies = new Map<string, Map<string, number>>()
313 private readonly records = new Map<string, { record: Recorded; file: string }>()
314 private readonly damagedPieces = new Set<string>()
315 private worktreeList: { at: number; roots: string[] } | undefined
316 /** Each record told with its tree, computed once. */
317 private readonly told = new Map<string, JournalEntry>()
318 /** Per session, the worktree it ran in. */
319 private readonly homes = new Map<string, string>()
320 /** Per newest commit, the first-parent line from it and how deep it was asked. */
321 private readonly chains = new Map<string, { depth: number; chain: Committed[] }>()
322
323 readonly root: string
324
325 constructor(root: string) {
326 this.root = root
327 }
328
329 /** The repository's worktrees, this root first, asked of git at most once per hold. */
330 async worktrees(io: JournalIo): Promise<string[]> {
331 const now = await io.now()
332 if (this.worktreeList !== undefined && now - this.worktreeList.at < WORKTREES_HOLD_MS) return this.worktreeList.roots
333 const listed = worktreesOf(await io.run(['git', 'worktree', 'list', '--porcelain'], this.root))
334 const roots = [...new Set([this.root, ...listed])]
335 this.worktreeList = { at: now, roots }
336 return roots
337 }
338
339 /** One look: lists every worktree's journal folder and reads only the files that moved, from where it left off. */
340 async scan(io: JournalIo): Promise<Scanned> {
341 const landed: string[] = []
342 let read = 0
343 const roots = await this.worktrees(io)
344 const listings = await Promise.all(roots.map(async root => ({ root, entries: await io.list(`${root}/.coherence/journal`).catch(() => []) })))
345 for (const { root, entries } of listings) {
346 for (const e of entries) {
347 if (e.kind !== 'file' || !isRecordFile(e.name)) continue
348 const path = `${root}/.coherence/journal/${e.name}`
349 let copies = this.copies.get(e.name)
350 if (copies === undefined) this.copies.set(e.name, (copies = new Map()))
351 copies.set(root, e.size)
352 const was = this.files.get(path)
353 if (was !== undefined && was.size === e.size && was.mtimeMs === e.mtimeMs) continue
354 if (was === undefined && this.readWhole.has(`${e.name}\0${e.size}`)) {
355 this.files.set(path, { size: e.size, mtimeMs: e.mtimeMs, bytes: e.size })
356 continue
357 }
358 // A file that shrank was rewritten: it is read from its start, and records already seen stay once.
359 const from = was === undefined || e.size < was.bytes ? 0 : was.bytes
360 let fresh: string
361 let isWhole = true
362 try {
363 if (e.size > READ_LIMIT) {
364 fresh = await io.tail(path, from)
365 isWhole = from + utf8Length(fresh) >= e.size
366 } else {
367 const text = await io.read(path)
368 fresh = text.slice(charsAt(text, from))
369 }
370 } catch {
371 continue
372 }
373 read++
374 const got = recordsIn(fresh)
375 for (const piece of got.damaged) this.damagedPieces.add(`${e.name}\0${piece}`)
376 for (const record of got.records) {
377 if (this.records.has(record.id)) continue
378 this.records.set(record.id, { record, file: e.name })
379 landed.push(record.id)
380 }
381 const bytes = from + utf8Length(fresh.slice(0, got.consumed))
382 // A read cut short is read again on the next look.
383 this.files.set(path, { size: isWhole ? e.size : -1, mtimeMs: e.mtimeMs, bytes })
384 if (isWhole && bytes === e.size) this.readWhole.add(`${e.name}\0${e.size}`)
385 }
386 }
387 return { landed, read }
388 }
389
390 /** How many pieces of the store files are no record. */
391 get damaged(): number {
392 return this.damagedPieces.size
393 }
394
395 /** How many records it has seen. */
396 get total(): number {
397 return this.records.size
398 }
399
400 /** How many escalations among every record seen still wait on the person. */
401 get waiting(): number {
402 return openEscalations([...this.records.values()].map(r => r.record))
403 }
404
405 /** The latest `n` records, oldest first. */
406 latest(n: number): Recorded[] {
407 return [...this.records.values()]
408 .map(r => r.record)
409 .sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id))
410 .slice(-n)
411 }
412
413 /** The worktree a session ran in: the one holding its hooks' trace (transient, never committed), else the one with the largest copy of its file. */
414 private async homeOf(io: JournalIo, session: string, file: string): Promise<string> {
415 const known = this.homes.get(session)
416 if (known !== undefined) return known
417 const copies = [...(this.copies.get(file) ?? new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1])
418 let home: string | undefined
419 for (const [root] of [...copies].sort((a, b) => (a[0] === this.root ? -1 : b[0] === this.root ? 1 : 0))) {
420 if (await io.exists(`${root}/.coherence/traces/${session}.jsonl`).catch(() => false)) {
421 home = root
422 break
423 }
424 }
425 home ??= copies[0]?.[0] ?? this.root
426 this.homes.set(session, home)
427 return home
428 }
429
430 /**
431 * The records told, each with its tree: computed once per record, from its
432 * own session's records and the trace and commits of the worktree it ran in.
433 */
434 async entries(io: JournalIo, records: readonly Recorded[]): Promise<JournalEntry[]> {
435 const bySession = new Map<string, Recorded[]>()
436 for (const r of records) if (!this.told.has(r.id)) bySession.set(r.session, [...(bySession.get(r.session) ?? []), r])
437 if (bySession.size > 0) {
438 const all = [...this.records.values()].map(r => r.record)
439 await Promise.all(
440 [...bySession].map(async ([session, untold]) => {
441 const home = await this.homeOf(io, session, this.records.get(untold[0]?.id ?? '')?.file ?? '')
442 const own = all.filter(r => r.session === session)
443 const commits = own.map(r => r.commit).filter((c): c is string => typeof c === 'string')
444 const newest = [...own].sort((a, b) => b.at.localeCompare(a.at)).map(r => r.commit).find((c): c is string => typeof c === 'string')
445 const depth = Math.min(2000, commits.length + 400)
446 // The worktrees share one object store, so a commit's first-parent line is the same from any of them; one at least as deep serves again.
447 const held = newest === undefined ? undefined : this.chains.get(newest)
448 let chain = held !== undefined && held.depth >= depth ? held.chain : undefined
449 if (chain === undefined) {
450 // The branch's own first-parent line from the newest record's commit, asked in the worktree the session ran in.
451 const log = newest === undefined ? '' : await io.run(['git', 'log', '--first-parent', '--name-only', '--format=@%h', '-n', String(depth + 200), newest], home)
452 chain = commitsOf(log)
453 if (newest !== undefined) this.chains.set(newest, { depth: depth + 200, chain })
454 }
455 const trace = await io.read(`${home}/.coherence/traces/${session}.jsonl`).catch(() => '')
456 const wanted = new Set(untold.map(r => r.id))
457 for (const entry of entriesOf(own, chain, writesOf(trace))) if (wanted.has(entry.id)) this.told.set(entry.id, entry)
458 }),
459 )
460 }
461 return records.flatMap(r => {
462 const entry = this.told.get(r.id)
463 return entry === undefined ? [] : [entry]
464 })
465 }
466}
467hooks/tree.ts 125 lines1// The shape of the repository a journal record touched: the files it names and
2// the files written around it, folded into a small tree drawn with guide
3// lines. Pure, so the tests hold it without an engine.
4
5import type { Contact } from '../types'
6
7/** One drawn line of the tree: its depth, the guide drawn before it (`│ ├─ `), and what it names. */
8export type TreeLine = { indent: number; guide: string; text: string; kind: 'dir' | 'file' | 'more'; isEdited: boolean; isSearched: boolean }
9
10/** How many lines a tree draws before it folds files into counts, then cuts. */
11export const TREE_LINES = 12
12/** How many contacts an agent keeps between edits. */
13export const CONTACTS_KEPT = 200
14
15type Node = { name: string; children: Map<string, Node>; isFile: boolean; isEdited: boolean; isSearched: boolean }
16
17const RANK: Record<Contact['kind'], number> = { search: 0, read: 1, edit: 2 }
18
19/** The contacts with one more, each path once, its strongest kind kept (an edit over a read over a search), newest last. */
20export function touched(contacts: readonly Contact[], more: readonly Contact[]): Contact[] {
21 const out = [...contacts]
22 for (const c of more) {
23 if (c.path === '' && c.kind !== 'search') continue
24 const at = out.findIndex(x => x.path === c.path)
25 const was = out[at]
26 if (was === undefined) out.push(c)
27 else if (RANK[c.kind] > RANK[was.kind]) out[at] = c
28 }
29 return out.slice(-CONTACTS_KEPT)
30}
31
32function node(name: string): Node {
33 return { name, children: new Map(), isFile: false, isEdited: false, isSearched: false }
34}
35
36/** Whether a searched path names a file rather than a folder: it has an extension. */
37const looksLikeFile = (name: string): boolean => /\.[A-Za-z0-9]+$/.test(name)
38
39function build(contacts: readonly Contact[]): { root: Node; isRootSearched: boolean } {
40 const root = node('')
41 let isRootSearched = false
42 for (const c of contacts) {
43 const parts = c.path.split('/').filter(p => p !== '' && p !== '.')
44 if (parts.length === 0) {
45 if (c.kind === 'search') isRootSearched = true
46 continue
47 }
48 let at = root
49 parts.forEach((part, i) => {
50 const next = at.children.get(part) ?? node(part)
51 at.children.set(part, next)
52 at = next
53 if (i === parts.length - 1) {
54 at.isFile = c.kind !== 'search' || looksLikeFile(part)
55 at.isEdited ||= c.kind === 'edit'
56 at.isSearched ||= c.kind === 'search'
57 }
58 })
59 }
60 return { root, isRootSearched }
61}
62
63const isDir = (n: Node): boolean => n.children.size > 0 || !n.isFile
64const order = (a: Node, b: Node): number => Number(isDir(b)) - Number(isDir(a)) || a.name.localeCompare(b.name)
65
66/**
67 * A folder's lines: its folders, files, a count of the files folded away and
68 * one line for the folders only searched, each under the guide its place
69 * draws. The top level stands bare; below it, `├─ ` marks an entry with
70 * more after it, `└─ ` the last, and `│ ` carries an open branch down.
71 */
72function lines(dir: Node, depth: number, fold: boolean, guide: string): TreeLine[] {
73 // Folders only searched, with nothing read in them, are one line: where the agent looked and found nothing to open.
74 const searchedOnly = (k: Node): boolean => isDir(k) && k.children.size === 0 && k.isSearched
75 const kids = [...dir.children.values()].sort(order).filter(k => !searchedOnly(k))
76 const looked = [...dir.children.values()].filter(searchedOnly).map(k => `${k.name}/`).sort()
77 const files = kids.filter(k => !isDir(k))
78 const folded = fold && files.length > 2
79 type Item = { line: Omit<TreeLine, 'indent' | 'guide'>; node?: Node }
80 const items: Item[] = []
81 for (const kid of kids) {
82 if (!isDir(kid)) {
83 if (folded && !kid.isEdited) continue
84 items.push({ line: { text: kid.name, kind: 'file', isEdited: kid.isEdited, isSearched: kid.isSearched } })
85 continue
86 }
87 // A folder holding one folder and nothing else draws as one line: src/adapters/.
88 let name = kid.name
89 let at = kid
90 while (at.children.size === 1 && !at.isSearched) {
91 const only = [...at.children.values()][0]
92 if (only === undefined || !isDir(only)) break
93 name += `/${only.name}`
94 at = only
95 }
96 items.push({ line: { text: `${name}/`, kind: 'dir', isEdited: false, isSearched: false }, node: at })
97 }
98 const hidden = folded ? files.filter(f => !f.isEdited).length : 0
99 if (hidden > 0) items.push({ line: { text: `${hidden} file${hidden === 1 ? '' : 's'}`, kind: 'more', isEdited: false, isSearched: false } })
100 if (looked.length > 0) items.push({ line: { text: `searched ${looked.join(' ')}`, kind: 'more', isEdited: false, isSearched: true } })
101 const out: TreeLine[] = []
102 items.forEach((item, i) => {
103 const isLast = i === items.length - 1
104 out.push({ ...item.line, indent: depth, guide: depth === 0 ? '' : `${guide}${isLast ? '└─ ' : '├─ '}` })
105 if (item.node !== undefined) out.push(...lines(item.node, depth + 1, fold, depth === 0 ? '' : `${guide}${isLast ? ' ' : '│ '}`))
106 })
107 return out
108}
109
110/**
111 * The tree the contacts make, at most `max` lines: in full when it fits, else
112 * with each folder's unedited files folded into a count, else cut with a count
113 * of the lines left out. A search of the whole project heads it.
114 */
115export function treeOf(contacts: readonly Contact[], max = TREE_LINES): TreeLine[] {
116 const { root, isRootSearched } = build(contacts)
117 const head: TreeLine[] = isRootSearched ? [{ indent: 0, guide: '', text: 'searched the whole project', kind: 'more', isEdited: false, isSearched: true }] : []
118 const full = [...head, ...lines(root, 0, false, '')]
119 if (full.length <= max) return full
120 const folded = [...head, ...lines(root, 0, true, '')]
121 if (folded.length <= max) return folded
122 const left = folded.length - (max - 1)
123 return [...folded.slice(0, max - 1), { indent: 0, guide: '', text: `… ${left} more line${left === 1 ? '' : 's'}`, kind: 'more', isEdited: false, isSearched: false }]
124}
125types/index.d.ts 58 lines1/** The verdict line an edit's check left for one tool call's row, by its tool_use_id. */
2export type CoherenceVerdict = { text: string; isFailed: boolean }
3
4/** One path a journal record touched, project-relative: one it changed, or one it only names. */
5export type Contact = { path: string; kind: 'read' | 'search' | 'edit' }
6
7/** One journal record as the Journal pane tells it: its own fields that say something, and the files it pinged with where the ping came from. */
8export type JournalEntry = {
9 id: string
10 kind: string
11 at: string
12 agent: string
13 /** The session that wrote it, from the record's own field. */
14 session?: string
15 chose?: string
16 over?: string[]
17 because?: string
18 human?: string
19 what?: string
20 evidence?: string
21 observation?: string
22 couldBe?: string[]
23 discriminatedBy?: string
24 expectation?: string
25 outcome?: string
26 of?: string
27 guard?: string
28 practice?: string
29 cites: string[]
30 pings: Contact[]
31 /** Where the pings came from: named by the record, written or committed since the record before, or named by what it cites. */
32 from: string[]
33}
34
35/** What the live view reads across: every record seen, the sessions among the latest shown, the worktrees looked in, and the pieces of the store files that were no record. */
36export type JournalLive = { total: number; sessions: number; worktrees: number; damaged: number }
37
38/**
39 * The journal the pane shows: one session's records, told, and how many of its
40 * escalations still wait on the person; or, with `live`, the latest records
41 * across every session and worktree, `session` then being 'live'.
42 */
43export type JournalShown = { session: string; records: JournalEntry[]; waiting: number; live?: JournalLive }
44
45declare module 'claude-code' {
46 interface PluginState {
47 coherence: {
48 verdict: StateFamily<CoherenceVerdict>
49 /** The journal the Journal pane shows, live or one session's, null until it is opened. */
50 journal: JournalShown | null
51 /** The records the person or the agent opened in the pane, and the trees drawn in full (`tree:<id>`). */
52 journalOpened: string[]
53 /** Whether the pane follows the journal's end, and how many records landed while the person read further up. */
54 journalView: { following: boolean; unseen: number }
55 }
56 }
57}
58