SLOPSHOPPER

coherence

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

newpanebandrowsguardcommand
★ 6v1.8.0MITupdated 2026-10-14PostHog/coherence/mods/coherence
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · coherence
│ ┃ Journal · live · 0 decisions · 0 defects · nothing waiting on you✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ live journal · 0 decisions · 0 defects · │ coherence │ │ ┃ nothing waiting on you ● coherence: Coherence│ Coherence: the mod found no Coherence │ │ ┃ latest 0 of 0 records · 0 sessions · 1 ⏺ Read(src/auth.ts) │ command line (COHERENCE_HOME, │ │ ┃ worktrees ⎿ Read 6 lines │ node_modules/@posthog/coherence, or the │ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ⏺ Bash(bun test) │ coherence │ │ ⎿ 3 pass, 1 fail │ Coherence: Scope did not open (no │ │ │ Coherence command line) │ │ ● Done. refresh now re╰────────────────────────────────────────────╯ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /scope │ │ coherence [ Scope ↗ ] [ Journal ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
coherence [ Scope ↗ ] [ Journal ]
Pane · Journal · live · 0 decisions · 0 defects · nothing waiting on you
live journal · 0 decisions · 0 defects · nothing waiting on you latest 0 of 0 records · 0 sessions · 1 worktrees
README

Coherence

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.

Scope's Structure view of Coherence itself: entrances on the left, components in the middle, colored routes showing how work flows from each entrance through the components, and core dependencies as rails along the bottom.

Quick setup: paste this into your agent

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.

See the system: the Structure view

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.

  • Entrances on the left are where work enters from outside: commands, routes, host events, tools. Each carries the trust level of the data or caller it lets in.
  • Components are the folders that carry a spec. They are laid out by how far they sit from an entrance.
  • Structural routes are drawn one color per entrance, following the path work takes through the components, in the order it flows.
  • Core dependencies, the components most others call, run as rails along the bottom so they don't tangle the map.
  • Interface identifiers (X1, X7, …) tag the places where one component uses another and a chokepoint or crossing stands there: solid when verified, hatched when only a requirement, red when broken.

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:

The run and refute route selected on the Structure map: the route is lit from the entrance through Coherence (root), Enforcement, Observation, and Economy; everything else is dimmed.

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.

How it helps

Agents start with clear definitions

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:

A concept in the Lexicon view: chokepoint, with its definition, two rejected alternatives each with its reason, and links to related concepts.

Intentions live in the structure, and the structure is checked

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.

  • A chokepoint is the one site every reference to a protected thing must pass through, like 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.
  • A totality oracle is a detector, usually a test, that checks the invariant over a whole set, for the cases where a chokepoint isn't practical.
  • Crossings mark the invariants that stand on a security boundary, naming the trust levels on either side. A crossing says which trust an invariant checks, not where it enters: a totality oracle counts as a control only on the entrances its entrances: line names.

The Invariants view: each invariant with its sentence, the reason it matters, its enforcement, and its latest verdict.

Violations surface at the edit, not in review

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 stay out of the way

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.

The Claude Code mod: one warm process per session

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.

Decisions are never buried in a transcript

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.

A decision record in the Journal view: the choice, two rejected alternatives, the reason, the human's words as the agent attributes them, and a citation of an earlier decision.

Methods are kept, not rediscovered

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.

Many agents, one picture

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.

Know what a change will cost

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.

Full setup: paste this into your agent

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.

Adopting part of a monorepo

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.

Platform

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

Source 8 files
hooks/register.tsx 431 lines
1// 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}
431
hooks/writers.ts 3 lines
1// 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"]
3
hooks/answer.ts 94 lines
1// 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}
94
hooks/journal-pane.tsx 429 lines
1// 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}
429
hooks/place.ts 11 lines
1// 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}
11
hooks/journal.ts 467 lines
1// 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}
467
hooks/tree.ts 125 lines
1// 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}
125
types/index.d.ts 58 lines
1/** 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