The learning loop: every main turn is kept in a ledger across sessions (phase, effort, steps, failed tool calls, agents, advisor calls), joined with the…

Claude Code mods and a separate Codex adapter for the ai-architect.tools harness. The Claude mods keep one concern per mod, state shared through dependencies:
| Mod | Owns | Depends on | |
|---|---|---|---|
cortex-guard | refusals: wiki pages only through wiki_write, worktrees inside <repo>/.claude/worktrees/ | nothing | |
cortex-wiki | /wiki <wiki/**.md> to PDF | nothing | |
zetetic-genius | state: the request grade (task class → effort), the genius patterns and skills it matches | nothing | |
zetetic-autopilot | context, policy, phases: effort ladder, model routing (a subagent's own definition first), pressure, offloaded tool results; the phase each loop is in and the advisor calls | zetetic-genius | |
zetetic-insights | insights: the turn ledger across sessions ($.store), joined with /insights session outcomes and .zetetic/goals; per-phase effort proposals, `/calibrate apply\ | reset`, the calibration file the autopilot reads | zetetic-autopilot, zetetic-genius |
cortex-cockpit | stats, ledger, tally, stages, hygiene; the /cortex pane draws the rest (PHASES, INSIGHTS, POLICY...) | the four above | |
mission-control | renderer; the /mission pane: the turn's lanes by phase (bar height = effort, advisor calls, failed calls), effort per phase, learning curves per phase. Pixels in kitty or Ghostty outside tmux, coloured cells elsewhere | zetetic-autopilot, zetetic-insights | |
harness-fleet | fleet: the owner's plugins, installed vs offered version, open PRs with CI, open issues (a defect is taken and fixed, a feature request goes to the owner); /fleet. Reads only (gh pr list, gh issue list, git remote): every outward action is a button the owner presses, which puts a prompt in front of the model | guard, genius, autopilot | |
superpowers | third-party skills library (obra/superpowers, MIT), pinned to v7.0.0 bb92a77: listed here so it installs with the mods; which skills we keep is decided after measurement | nothing |
Each mod is validated, tested and type-checked on its own:
cd mods/<mod> && claude plugin validate . && claude plugin test && npx -y -p typescript tsc -p . --noEmit
tsc needs the engine to have loaded the mod once (it lays .claude-plugin/types/); keep --noEmit, since a plain tsc -p . writes .js files beside the sources. For hot reload in a session, link the mod into that session's ~/.claude/dev-mods/<session>/ folder; the engine watches the folder a link names.
A mod cannot import a file from another mod (the engine refuses it: "outside the plugin's folder"), so the few small pure files several mods need are copied into each of them: hooks/paths.ts (where ~/ lands: HOME, else USERPROFILE, CLAUDE_CONFIG_DIR for ~/.claude) in five mods, and hooks/rules.ts (the worktree roots, the python names) in cortex-guard and cortex-cockpit, each with its test. After editing one, copy it over the others; this fails when a copy differs:
sh scripts/check-shared.sh
.claude-plugin/marketplace.json lists each mod as "source": "./mods/<mod>", so the repository is the marketplace. From another machine:
/plugin install <mod> --marketplace cdeust/claude-mods
y adds the marketplace, then the user scope. On this machine a folder marketplace reads the mods from the checkout, with no copy: claude plugin marketplace add <this folder>, then /plugin install <mod> and /reload-plugins after an edit.
claude plugin marketplace add and claude plugin install both write enabledPlugins and extraKnownMarketplaces into settings.json. Where that file cannot be changed, load the mods by directory instead: nothing is written, and each mod is registered for the session as <name>@inline.
MODS=/path/to/claude-mods/mods
# repeat --plugin-dir once per mod
claude \
--plugin-dir $MODS/cortex-guard \
--plugin-dir $MODS/cortex-wiki \
--plugin-dir $MODS/zetetic-genius \
--plugin-dir $MODS/zetetic-autopilot \
--plugin-dir $MODS/zetetic-insights \
--plugin-dir $MODS/cortex-cockpit \
--plugin-dir $MODS/mission-control \
--plugin-dir $MODS/harness-fleet
# or one variable, colon separated
CLAUDE_CODE_PLUGIN_DIRS=$MODS/cortex-guard:$MODS/cortex-wiki:$MODS/zetetic-genius:$MODS/zetetic-autopilot:$MODS/zetetic-insights:$MODS/cortex-cockpit:$MODS/mission-control:$MODS/harness-fleet claude
# an alias in the shell profile: it sets MODS itself, because the alias body is single-quoted and
# is expanded each time it runs, not when the profile is read
alias claude-mods='MODS=/path/to/claude-mods/mods; CLAUDE_CODE_PLUGIN_DIRS=$MODS/cortex-guard:$MODS/cortex-wiki:$MODS/zetetic-genius:$MODS/zetetic-autopilot:$MODS/zetetic-insights:$MODS/cortex-cockpit:$MODS/mission-control:$MODS/harness-fleet claude'
Which form to prefer: an interactive session may ask for a login when CLAUDE_CONFIG_DIR points at an empty directory (seen once in an isolated directory, where headless claude -p did not ask). On the machine's own configuration directory, which is already logged in, that does not apply; use the repeated --plugin-dir or the alias there, and keep an isolated CLAUDE_CONFIG_DIR for tests only.
claude plugin list shows them under "Session-only plugins" with Status: loaded.
What then must hold:
dependencies of a mod are resolved among the mods loaded in the same command, so load a mod together with what it names: zetetic-autopilot needs zetetic-genius; cortex-cockpit needs cortex-guard, zetetic-autopilot and zetetic-genius; harness-fleet needs the same three. A mod whose dependency is absent is listed disabled, with the error Dependency "<name>" is not installed.settings.json under pluginConfigs), so every default in a plugin.json has to be right on any machine:| Mod | Option | Default | Notes |
|---|---|---|---|
cortex-guard | hygiene_script | ~/Developments/disk-hygiene/disk_hygiene.py | ~/ is the home of the user running the session, read at runtime. When the file is not there, one toast per session says hygiene script not found at <path>; worktrees are not registered; an empty value disables the registration without a toast. A python3 that fails to start gets one toast naming the cause, and no further worktree is registered for the session (no retry) |
zetetic-genius | mode, classifier_model | observe, haiku | the grade is one low-effort completion on the haiku alias |
zetetic-autopilot | policy_mode, quota_pressure_percent, offload_threshold_chars, offload_preview_chars | enforce, 80, 30000, 2000 | a result past the threshold is written whole to a file in the session's tool-results directory (the engine's cleanupPeriodDays sweeps it, 30 days by default; the mod deletes nothing); the model reads the preview, the path and the size |
cortex-cockpit | surface, cortex_server | ink, plugin_hypermnesia-mcp_cortex | the server name as /mcp lists it |
mission-control | renderer | auto | auto draws pixels in kitty or Ghostty outside tmux (TMUX, TERM, TERM_PROGRAM, KITTY_WINDOW_ID), cells elsewhere; cells or pixels forces one |
harness-fleet | github_owner, refresh_on_start | cdeust, true | reads gh pr list, gh issue list and git remote |
python3 (guard), the Cortex MCP server by name (cockpit), gh and a route to api.github.com (fleet), the files under ~/.claude/plugins/ (fleet), pandoc and xelatex (wiki), and the model aliases (genius, autopilot)./cortex check tests each of these on the machine where it runs and prints one line per check, ok, FAIL or n/a. cortex-wiki and harness-fleet count as loaded only when the command they register (/wiki, /fleet) was registered by that plugin; a user's own wiki.md, an MCP prompt or another plugin's command of the same name prints FAIL, and a plugin command whose plugin the engine does not name prints n/a. A refusal from the sandbox reaches the line as the first 160 characters of its own text, so it can be pasted back. It only reads: python3 --version, gh --version, gh api rate_limit, git remote, the two fleet files, memory_stats on the Cortex server.
Two limits of the check are stated in its output. A model alias is only proven by a paid completion, which the check does not make, so it prints n/a for the models. The options of cortex-guard and harness-fleet are not readable from another mod (they publish none in their state, and the engine did not list their rows in the run below); the check then says so, and checks the hygiene script at its default location.
Measured, on one Mac (Claude Code 2.1.293, no company sandbox): with a pristine settings.json holding {} and an isolated CLAUDE_CONFIG_DIR, loading all six by CLAUDE_CODE_PLUGIN_DIRS lists all six as loaded and leaves settings.json at {}; cortex-cockpit or zetetic-autopilot loaded alone is disabled with the dependency error above; /cortex check run headless (claude -p "/cortex check") answers one line per check, and reports harness-fleet and cortex-wiki as not loaded when only the other four are, and with user commands wiki.md and fleet.md present it still reports them not loaded (the engine reports a directory-loaded mod's command with source: plugin and plugin set to the bare manifest name).
Not measured: the managed-settings lock itself (it could not be reproduced), whether a company policy accepts --plugin-dir, any refusal of a real sandbox (blocked network, denied exec, unreadable ~/.claude), the Cortex server answering through /cortex check (the headless run had no MCP server connected, and the check printed the engine's refusal for it), the model aliases, and whether /config lists the options of directory-loaded mods in an interactive session.
hooks/register.tsx 395 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnUsage } from 'claude-code'
3
4import type { AgentRecord, Calibration, Design, InsightsState, ProbeCount, ProbeReason, TurnRecord } from '../types'
5import { briefOf, mechanicalShare } from './brief'
6import { delegationStats, parseDesign } from './delegations'
7import { recurringIn, schedulePush, setServer } from './handover'
8import {
9 CALIBRATION_PATH,
10 FACETS_DIR,
11 FACET_SESSIONS_CAP,
12 GOALS_DIR,
13 type Outcome,
14 advisorStats,
15 agentStats,
16 applied,
17 emptyCalibration,
18 goalStatus,
19 offRung,
20 outcomeOf,
21 parseCalibration,
22 phaseStats,
23 proposals,
24 withAgent,
25} from './learn'
26import { frictionsBySession } from './ledger'
27import { sessionModels } from './models'
28import { originStamp, observeCall, setOrigin, takeObserved } from './observe'
29import { combineOutcomes } from './outcome'
30import { type ProbeInfo, probesOf, withoutProbes } from './probes'
31import { cut, expandHome, INITIAL } from './shared'
32import { held, ledger, loadLedger, loaded, markEnded, markTranscript, note, saved } from './store-io'
33import { trends } from './trend'
34import type { BashResult } from './events'
35
36const insights = atom({ plugin: 'zetetic-insights', key: 'insights' } as const, INITIAL)
37
38// The dependencies' state, as their contracts declare it; literals, as the validator lists them.
39const AUTOPILOT_PHASES = { plugin: 'zetetic-autopilot', key: 'phases' } as const
40const AUTOPILOT_POLICY = { plugin: 'zetetic-autopilot', key: 'policy' } as const
41const GENIUS_STATE = { plugin: 'zetetic-genius', key: 'state' } as const
42
43// The ledger's insights state (shared.ts), written here: the turn records, the panel's statistics, the calibration.
44
45// Module state beside the held ledger (store-io.ts): what recompute reads, rebuilt at start.
46let outcomes = new Map<string, Outcome>()
47let pending: { agents: AgentRecord[]; dropped: number } = { agents: [], dropped: 0 }
48// Every session's probe decision, over the ledger as last computed (recompute).
49let probes = new Map<string, ProbeInfo>()
50
51// The design each spawned agent was dispatched under, by agentId, kept for the agent's lifetime (a
52// resumed agent completes more than one turn). Bounded: the oldest entry is dropped past the cap.
53export const DESIGN_MAP_CAP = 200 // source: own choice, far above the agents running at once, small enough to hold in memory
54const designs = new Map<string, Design | null>()
55// The steps and failed calls already recorded per agentId, and the row they belong to (the since of
56// its first run): loop.runs hold the agent's cumulative runs, a record keeps this turn's delta only.
57// A row the autopilot evicted and reopened starts again from empty, so its baseline is 0. Same cap.
58const recorded = new Map<string, { steps: number; errors: number; since: number | undefined }>()
59
60const bounded = <V,>(map: Map<string, V>, key: string, value: V): void => {
61 map.delete(key)
62 map.set(key, value)
63 while (map.size > DESIGN_MAP_CAP) {
64 const oldest = map.keys().next()
65 if (oldest.done === true) break
66 map.delete(oldest.value)
67 }
68}
69
70const remember = (agentId: string, design: Design | null): void => bounded(designs, agentId, design)
71
72const BRIEF_SECTION = 'zetetic-insights:self-calibration'
73
74
75// The outcomes /insights wrote for the ledger's last sessions. No facets folder is "never run",
76// not an error; an unreadable or malformed facet is skipped and named, the others are read.
77async function loadOutcomes($: EngineInterface): Promise<string | null> {
78 outcomes = new Map()
79 const sessions = [...new Set(ledger().map((r) => r.session))].slice(-FACET_SESSIONS_CAP)
80 const failed: string[] = []
81 let dir: string
82 try {
83 dir = await expandHome($, FACETS_DIR)
84 if (!(await $.fs.exists(dir))) return null
85 } catch (error) {
86 return cut(error)
87 }
88 for (const session of sessions) {
89 const path = `${dir}/${session}.json`
90 try {
91 if (await $.fs.exists(path)) outcomes.set(session, outcomeOf(await $.fs.read(path)))
92 } catch (error) {
93 failed.push(`${session}: ${cut(error)}`)
94 }
95 }
96 return failed.length === 0 ? null : `${failed.length} skipped, first ${failed[0]}`.slice(0, 160)
97}
98
99async function readGoals($: EngineInterface): Promise<{ goals: InsightsState['goals']; error: string | null }> {
100 try {
101 const dir = `${await $.session.cwd()}/${GOALS_DIR}`
102 if (!(await $.fs.exists(dir))) return { goals: [], error: null }
103 const files = (await $.fs.list(dir)).filter((f) => f.kind === 'file' && f.name.endsWith('.md'))
104 const goals = []
105 for (const f of files) goals.push({ name: f.name.replace(/\.md$/, ''), status: goalStatus(await $.fs.read(`${dir}/${f.name}`)) })
106 return { goals, error: null }
107 } catch (error) {
108 return { goals: [], error: cut(error) }
109 }
110}
111
112async function readCalibration($: EngineInterface): Promise<{ calibration: Calibration; error: string | null }> {
113 try {
114 const path = await expandHome($, CALIBRATION_PATH)
115 if (!(await $.fs.exists(path))) return { calibration: emptyCalibration(), error: null }
116 return { calibration: parseCalibration(await $.fs.read(path)), error: null }
117 } catch (error) {
118 return { calibration: emptyCalibration(), error: cut(error) }
119 }
120}
121
122async function writeCalibration($: EngineInterface, c: Calibration): Promise<void> {
123 await $.fs.write(await expandHome($, CALIBRATION_PATH), `${JSON.stringify(c, null, 2)}\n`)
124}
125
126// Everything the panel shows, from the ledger and what was read beside it; enforced, a proposal
127// the evidence supports is written at once.
128async function recompute($: EngineInterface, extra: Partial<InsightsState> = {}): Promise<void> {
129 const at = await $.clock.now()
130 const s = { ...(await read($, insights)), ...extra }
131 const every = ledger()
132 // Probe sessions (hooks/probes.ts) are counted, never learned from: every statistic below reads `all`.
133 probes = probesOf(every, held.layout?.transcripts)
134 const all = withoutProbes(every, probes)
135 const joined = combineOutcomes(all, outcomes)
136 let calibration = s.calibration
137 let phases = phaseStats(all, joined.outcomes, calibration.changes)
138 let ps = proposals(phases, calibration)
139 if (s.mode === 'enforce' && ps.length > 0) {
140 calibration = applied(calibration, ps, at)
141 await writeCalibration($, calibration)
142 phases = phaseStats(all, joined.outcomes, calibration.changes)
143 ps = []
144 }
145 await update($, insights, () => ({
146 ...s,
147 turns: all.length,
148 sessions: new Set(all.map((r) => r.session)).size,
149 judgedSessions: [...joined.outcomes.values()].filter((o) => o !== 'unclear').length,
150 probes: probeCount(every, probes),
151 outcomeSources: joined.sources,
152 phases,
153 agents: agentStats(all),
154 advisor: advisorStats(all),
155 trends: trends(all, calibration.changes),
156 models: sessionModels(every, probes),
157 delegations: delegationStats(all),
158 mechanical: mechanicalShare(all),
159 calibration,
160 proposals: ps,
161 updatedAt: at,
162 }))
163}
164
165const probeCount = (records: readonly TurnRecord[], decided: ReadonlyMap<string, ProbeInfo>): ProbeCount => {
166 const byReason: Record<ProbeReason, number> = { 'temp-cwd': 0, 'non-interactive-1-step': 0, 'one-step-unknown': 0 }
167 let sessions = 0
168 for (const info of decided.values()) {
169 if (!info.probe || info.reason === null) continue
170 sessions += 1
171 byReason[info.reason] += 1
172 }
173 return { sessions, turns: records.filter((r) => decided.get(r.session)?.probe === true).length, byReason }
174}
175
176async function start($: EngineInterface, mode: InsightsState['mode']): Promise<void> {
177 pending = { agents: [], dropped: 0 }
178 recorded.clear()
179 note.frictionError = null
180 const ledgerError = await loadLedger($)
181 const facetsError = await loadOutcomes($)
182 const g = await readGoals($)
183 const c = await readCalibration($)
184 // The only place the brief is computed: recompute keeps it, so the system prompt stays the same
185 // within a start. /resume and fork call start() again: the resumed session gets fresh evidence, at
186 // the cost of one cache miss.
187 const frictions = held.layout === null ? undefined : new Map([...frictionsBySession(held.layout)].filter(([s]) => s !== held.ownSid))
188 const brief = briefOf(ledger(), frictions, probesOf(ledger(), held.layout?.transcripts))
189 await recompute($, {
190 mode,
191 ledgerError,
192 facetsError,
193 goals: g.goals,
194 goalsError: g.error,
195 calibration: c.calibration,
196 calibrationError: c.error,
197 brief,
198 frictions: recurringIn(held.layout, ledger(), held.ownSid),
199 })
200 await schedulePush($, held.layout, ledgerError)
201}
202
203// One main turn, as the autopilot's phases and policy and the genius grade left it.
204async function recordTurn($: EngineInterface): Promise<void> {
205 const phases = (await $.state.get(AUTOPILOT_PHASES)).value
206 const main = phases?.loops.find((l) => l.id === 'main')
207 if (phases === undefined || main === undefined || main.runs.length === 0) return
208 const since = main.runs[0]?.since ?? 0
209 const grade = (await $.state.get(GENIUS_STATE)).value?.classified
210 const policy = (await $.state.get(AUTOPILOT_POLICY)).value
211 const uses = phases.advisor.uses.filter((u) => u.turnId === phases.turnId)
212 const sid = await $.session.id()
213 if (!(await loaded($, sid))) return
214 const seen = takeObserved()
215 const record: TurnRecord = {
216 session: sid,
217 turnId: phases.turnId ?? '',
218 at: await $.clock.now(),
219 taskClass: grade !== null && grade !== undefined && grade.turnId === phases.turnId ? grade.taskClass : null,
220 main: main.runs.map((r) => ({ phase: r.phase, effort: r.effort, steps: r.steps, errors: r.errors, ...(r.reads === undefined ? {} : { reads: r.reads }) })),
221 mainModel: main.model,
222 agents: pending.agents,
223 ...(pending.dropped > 0 ? { agentsDropped: pending.dropped } : {}),
224 advisor: {
225 calls: uses.reduce((n, u) => n + u.calls, 0),
226 beforeWrite: uses.filter((u) => u.when === 'before-write').reduce((n, u) => n + u.calls, 0),
227 atCompletion: uses.filter((u) => u.when === 'at-completion').reduce((n, u) => n + u.calls, 0),
228 },
229 stuck: policy?.decisions.filter((d) => d.kind === 'stuck' && d.at >= since).length ?? 0,
230 ...originStamp(),
231 ...(seen.events.length > 0 ? { events: seen.events } : {}),
232 ...(seen.eventsDropped > 0 ? { eventsDropped: seen.eventsDropped } : {}),
233 frictions: seen.frictions,
234 }
235 pending = { agents: [], dropped: 0 }
236 await saved($, sid, [...held.own, record])
237}
238
239
240// A finished agent goes to main's current turn. When that turn already completed (an agent that
241// ran in the background past it), it joins that turn's record instead of the next turn's.
242async function recordAgent($: EngineInterface, agentId: string, usage: TurnUsage | undefined): Promise<void> {
243 const phases = (await $.state.get(AUTOPILOT_PHASES)).value
244 const loop = phases?.loops.find((l) => l.id === agentId)
245 if (phases === undefined || loop === undefined) return
246 const since = loop.runs[0]?.since
247 const total = { steps: loop.runs.reduce((n, r) => n + r.steps, 0), errors: loop.runs.reduce((n, r) => n + r.errors, 0) }
248 const known = recorded.get(agentId)
249 const before = known !== undefined && known.since === since ? known : { steps: 0, errors: 0 }
250 bounded(recorded, agentId, { ...total, since })
251 const agent: AgentRecord = {
252 agentId,
253 type: loop.type,
254 phase: loop.phase,
255 model: loop.model,
256 steps: Math.max(0, total.steps - before.steps),
257 errors: Math.max(0, total.errors - before.errors),
258 // Absent when the spawn was never seen (a reload); null when it carried no DESIGN block.
259 ...(designs.has(agentId) ? { design: designs.get(agentId) ?? null } : {}),
260 ...(usage === undefined
261 ? {}
262 : { tokensIn: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens, tokensOut: usage.output_tokens }),
263 }
264 const sid = await $.session.id()
265 if (!(await loaded($, sid))) return
266 const last = held.own[held.own.length - 1]
267 if (last === undefined || phases.turnId === null || last.turnId !== phases.turnId) {
268 pending = withAgent(pending, agent)
269 return
270 }
271 const joined = withAgent({ agents: last.agents, dropped: last.agentsDropped ?? 0 }, agent)
272 const record = { ...last, agents: joined.agents, ...(joined.dropped > 0 ? { agentsDropped: joined.dropped } : {}) }
273 await saved($, sid, [...held.own.slice(0, -1), record])
274 await recompute($)
275}
276
277
278async function calibrate($: EngineInterface, args: string): Promise<string> {
279 const s = await read($, insights)
280 const at = await $.clock.now()
281 if (args === 'reset') {
282 await writeCalibration($, { ...emptyCalibration(), updatedAt: at })
283 await recompute($, { calibration: emptyCalibration(), calibrationError: null })
284 return 'Calibration reset: every phase runs its tool loop at the ladder default (medium).'
285 }
286 if (args === 'apply') {
287 if (s.proposals.length === 0) return 'No proposal: the evidence supports no change yet.'
288 const c = applied(s.calibration, s.proposals, at)
289 await writeCalibration($, c)
290 await recompute($, { calibration: c, calibrationError: null })
291 return `Applied: ${s.proposals.map((p) => `${p.phase} ${p.from} → ${p.to}`).join(', ')}.`
292 }
293 const now = Object.entries(s.calibration.phases).map(([p, e]) => `${p} ${e}`)
294 const off = offRung(s.phases, s.calibration).map((o) => `${o.phase} ran ${o.ran.join(', ')} steps, none enough at ${o.now}`)
295 return `Ledger: ${s.turns} turns over ${s.sessions} sessions, ${s.judgedSessions} judged by /insights. Calibration: ${now.length === 0 ? 'none (medium everywhere)' : now.join(', ')}. Proposals: ${
296 s.proposals.length === 0 ? 'none' : s.proposals.map((p) => `${p.phase} ${p.from} → ${p.to} (${p.evidence})`).join('; ')
297 }.${off.length === 0 ? '' : ` No proposal can come from: ${off.join('; ')}.`} /calibrate apply or /calibrate reset.`
298}
299
300type On = Parameters<Register>[0]
301
302// Session lifecycle: the ledger is marked ended, loaded at start, reloaded after /clear, /resume and /branch.
303function onSession(on: On, mode: InsightsState['mode']): void {
304 on('classic.SessionEnd', async ($, e, next) => {
305 await markTranscript($, e.session_id, e.transcript_path)
306
307 return next(e)
308 }).catch(($, e, next) => next(e))
309
310 on('session.end', async ($, e, next) => {
311 await markEnded($, e.sessionId)
312
313 return next(e)
314 }).catch(($, e, next) => next(e))
315
316 on('session.start', async ($, e, next) => {
317 setOrigin(e.isInteractive, e.cwd, [await $.env.get('TMPDIR'), await $.env.get('TEMP'), await $.env.get('TMP')])
318 await start($, mode)
319 try {
320 await $.command.register({ name: 'calibrate', description: 'Show, apply or reset the per-phase effort the insights propose', argumentHint: 'apply|reset', immediate: true })
321 } catch (error) {
322 $.ui.toast(`/calibrate is taken by another plugin: ${cut(error).slice(0, 80)}`)
323 }
324
325 return next(e)
326 })
327
328 // /clear, /resume and /branch reset $.state and never fire session.start again.
329 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
330 await start($, mode)
331
332 return next(e)
333 }).catch(($, e, next) => next(e))
334}
335
336// The turn: its record at turn.complete, and what its tool calls showed.
337function onTurn(on: On): void {
338 on('turn.complete', async ($, e, next) => {
339 if (e.agentId !== undefined) await recordAgent($, e.agentId, e.usage)
340 else {
341 await recordTurn($)
342 await recompute($)
343 }
344
345 return next(e)
346 })
347
348 // Observer: the outcome events and the frictions the turn shows, from the calls themselves (no
349 // transcript). The call's result is returned untouched; a failure to observe it is not the call's.
350 on('tool.call', async ($, e, next) => {
351 const ran = await next(e)
352 try {
353 await observeCall($, e as unknown as { tool: unknown; agentId?: string; command?: unknown }, ran as unknown as BashResult)
354 } catch {
355 // the observation failed: the turn simply shows fewer events, the call is unaffected
356 }
357
358 return ran
359 })
360}
361
362// What the model reads and what the user types: the frozen brief, the spawn's design, /calibrate.
363function onPrompt(on: On): void {
364 // Appends the frozen self-calibration section last, on the session side of the cache boundary; the
365 // engine's own sections are returned as they came. A state without a brief (older version, empty
366 // ledger) adds nothing. The id is unique in a list: an existing one is replaced, never doubled.
367 on('prompt.compose', async ($, e, next) => {
368 const r = await next(e)
369 const { brief } = await read($, insights)
370 if (typeof brief !== 'string') return r
371 const section = { id: BRIEF_SECTION, text: brief, scope: 'session' } as const
372
373 return { ...r, sections: [...r.sections.filter((x) => x.id !== BRIEF_SECTION), section] }
374 }).catch(($, e, next) => next(e))
375
376 // Observes only: the result is the spawn's own, returned untouched; a throw here never blocks it.
377 on('agent.spawn', async ($, e, next) => {
378 const r = await next(e)
379 if (r.agentId !== undefined) remember(r.agentId, parseDesign(e.prompt))
380
381 return r
382 }).catch(($, e, next) => next(e))
383
384 on('command.run', { command: 'calibrate' }, async ($, e) => ({ text: await calibrate($, (e.args ?? '').trim()) }))
385}
386
387export const register: Register = (on, options) => {
388 const mode: InsightsState['mode'] = options.learning_mode === 'enforce' ? 'enforce' : 'observe'
389 if (options.cortex_server !== undefined) setServer(String(options.cortex_server))
390 held.ownSid = null // a load or a hot reload: the module holds no ledger until it reads the store again
391 onSession(on, mode)
392 onTurn(on)
393 onPrompt(on)
394}
395hooks/brief.ts 127 lines1// The self-calibration text the model reads at the end of its system prompt: its own costly habits,
2// as the ledger measured them. Measured facts only, no advice; the model draws its own conclusions.
3// Pure, so the register and the tests share it. The register computes it once at session start and
4// freezes it: a system prompt that changed mid-session would void its prompt cache on every request.
5
6import type { DelegationStats, FrictionRow, MechanicalShare, ModelFamily, SessionModels, TurnRecord } from '../types'
7import { delegationStats } from './delegations'
8import { type Friction, recurringOver } from './friction'
9import { familyOf, sessionModels } from './models'
10import { type ProbeInfo, withoutProbes } from './probes'
11
12export const BRIEF_SESSIONS = 10 // source: own choice, the last sessions the mechanical share is measured over; recent habits, enough turns for a share
13export const BRIEF_MAX_LINES = 6 // source: own choice, a system-prompt section the model reads on every request stays short
14export const BRIEF_LINE_CAP = 200 // source: own choice, characters kept of a line; a long agent type or flag list must not grow the prompt
15
16// The one predicate of "mechanical work" is zetetic-autopilot's (phases.ts isFileReadStep): a step whose
17// every tool call is Read, Grep or Glob. The autopilot counts it per run (`reads`); this file only
18// sums the counts of main's steps on opus or fable. Not the analysis phase: that phase also holds
19// delegations, Cortex recalls, skills and read-only Bash.
20const HEAVY: readonly ModelFamily[] = ['opus', 'fable'] // source: delegations.ts overkill, the families a cheaper model could replace
21const isHeavy = (r: TurnRecord): boolean => HEAVY.includes(familyOf(r.mainModel ?? ''))
22
23// The order the families are listed in (models.ts ORDER).
24const ORDER: readonly ModelFamily[] = ['haiku', 'sonnet', 'opus', 'fable', 'other', 'unknown'] // source: models.ts ORDER
25
26// The one window of the brief: the records of the newest `sessions` sessions, by each one's newest
27// record, in ledger order. A session whose oldest turns the ledger trimmed counts what it still holds.
28export const windowOf = (records: readonly TurnRecord[], sessions: number = BRIEF_SESSIONS): { records: TurnRecord[]; sessions: number } => {
29 const newest = new Map<string, number>()
30 for (const r of records) newest.set(r.session, Math.max(newest.get(r.session) ?? r.at, r.at))
31 const keep = new Set(
32 [...newest.entries()]
33 .sort((a, b) => a[1] - b[1])
34 .slice(-sessions)
35 .map(([session]) => session),
36 )
37 return { records: records.filter((r) => keep.has(r.session)), sessions: keep.size }
38}
39
40// Main's file-read steps on opus or fable over the records' sessions (the newest `sessions` of them),
41// against the steps of the same records that were measured. A run without `reads` (recorded before it
42// was counted) is unmeasured: its steps are in `unmeasured`, never in heavySteps as zero reads.
43// heavySteps + unmeasured = all of main's steps on those models.
44export const mechanicalShare = (records: readonly TurnRecord[], sessions: number = BRIEF_SESSIONS): MechanicalShare => {
45 const window = windowOf(records, sessions)
46 let heavySteps = 0
47 let heavyReads = 0
48 let unmeasured = 0
49 for (const r of window.records) {
50 if (!isHeavy(r)) continue
51 for (const run of r.main) {
52 if (run.reads === undefined) unmeasured += run.steps
53 else {
54 heavySteps += run.steps
55 heavyReads += run.reads
56 }
57 }
58 }
59 return { sessions: window.sessions, heavyReads, heavySteps, unmeasured, share: heavySteps === 0 ? null : heavyReads / heavySteps }
60}
61
62// The delegations the outcome events judged, only when some are (merged with an approving review, reverted, closed): "; judged by events: 2 achieved, 1 not".
63const judgedText = (d: DelegationStats): string =>
64 d.outcomes.achieved + d.outcomes['not-achieved'] === 0 ? '' : `; judged by events: ${d.outcomes.achieved} achieved, ${d.outcomes['not-achieved']} not`
65
66const percent = (part: number, whole: number): string => {
67 const p = Math.round((100 * part) / whole)
68 return p === 0 ? '<1%' : `${p}%`
69}
70
71const stepsByModel = (models: readonly SessionModels[]): { total: number; line: string | null } => {
72 const by = new Map<ModelFamily, number>()
73 for (const s of models) for (const m of s.steps) by.set(m.family, (by.get(m.family) ?? 0) + m.steps)
74 const total = [...by.values()].reduce((n, v) => n + v, 0)
75 if (total === 0) return { total, line: null }
76 const parts = ORDER.flatMap((f) => ((by.get(f) ?? 0) > 0 ? [`${f} ${percent(by.get(f) ?? 0, total)}`] : []))
77 return { total, line: `- steps by model: ${parts.join(', ')}.` }
78}
79
80// At most BRIEF_MAX_LINES lines of BRIEF_LINE_CAP characters; null when nothing is measured: no
81// session with a step, no judged delegation, no share and no recurring friction. The friction line is
82// the last one: with the header and the four measures above it the brief is exactly BRIEF_MAX_LINES,
83// and were a line ever added, this is the one that yields (the slice cuts from the end).
84export const composeBrief = (o: { delegations: DelegationStats; models: readonly SessionModels[]; mechanical: MechanicalShare; frictions?: readonly FrictionRow[] }): string | null => {
85 const { delegations: d, mechanical: m } = o
86 const top = o.frictions?.[0]
87 const steps = stepsByModel(o.models.filter((m) => !m.probe))
88 if (steps.total === 0 && d.total === 0 && m.share === null && top === undefined) return null
89 const lines = [`Self-calibration (zetetic-insights ledger, last ${m.sessions} sessions). Measured, not prescribed:`]
90 if (m.share !== null)
91 lines.push(
92 `- main thread on opus/fable: ${Math.round(100 * m.share)}% of its measured steps only read files (Read, Grep, Glob); those reads need no heavy model.${m.unmeasured > 0 ? ` (${m.unmeasured} older steps unmeasured)` : ''}`,
93 )
94 if (d.total > 0)
95 lines.push(
96 `- delegations: ${d.total} (${d.designed} with a DESIGN block); overkill ${d.flags.overkill}, underpowered ${d.flags.underpowered}, undesigned ${d.flags.undesigned}, rerouted ${d.flags.rerouted}${judgedText(d)}.`,
97 )
98 const last = d.recent[d.recent.length - 1]
99 if (last !== undefined)
100 lines.push(`- latest flagged: ${last.type} on ${last.model} (${last.effort ?? 'effort unset'}), ${last.steps} steps, ${last.errors} failed: ${last.flags.join(', ')}.`)
101 if (steps.line !== null) lines.push(steps.line)
102 if (top !== undefined) lines.push(`- recurring friction: ${top.signature} (${top.kind}, ${top.sessions} sessions, ${top.count} times)`)
103
104 return lines
105 .slice(0, BRIEF_MAX_LINES)
106 .map((l) => l.slice(0, BRIEF_LINE_CAP))
107 .join('\n')
108}
109
110// The brief of a ledger: every line is computed from the same BRIEF_SESSIONS newest sessions, so the
111// header's count is true of all of them, the frictions' too (`frictions`: what was measured, by session).
112// The panel's own delegations and models stay on the whole ledger.
113export const briefOf = (records: readonly TurnRecord[], frictions: ReadonlyMap<string, readonly Friction[]> = new Map(), probes: ReadonlyMap<string, ProbeInfo> = new Map()): string | null => {
114 // Probe sessions (hooks/probes.ts) are not the model's habits: they take no slot of the window.
115 const w = windowOf(withoutProbes(records, probes))
116
117 return composeBrief({
118 delegations: delegationStats(w.records),
119 models: sessionModels(w.records, probes),
120 mechanical: mechanicalShare(w.records),
121 frictions: recurringOver(frictions, windowSessions(w.records)),
122 })
123}
124
125// The sessions of a window's records.
126export const windowSessions = (records: readonly TurnRecord[]): Set<string> => new Set(records.map((r) => r.session))
127hooks/delegations.ts 146 lines1// What the orchestrator asked of each delegation and what came of it: the DESIGN block its prompt
2// opened with (the dispatch-contract skill), kept beside the agent's outcome, and four predicates
3// that question a delegation. Each predicate is written once, in flagsOf; pure, so the register and
4// the tests share it.
5
6import type { AgentRecord, Design, DelegationFlag, DelegationRow, DelegationStats, OutcomeEvent, TurnRecord } from '../types'
7import { RAISE_RATE } from './learn'
8import { familyOf } from './models'
9import { eventsBySession, judge } from './outcome'
10
11export const DESIGN_FIELD_CAP = 80 // source: own choice, characters kept of each design field; the block states a reason, not an essay
12export const OVERKILL_STEPS = 5 // source: own choice, an opus or fable agent that took at most this many steps did a small job
13export const UNDERPOWERED_MIN_STEPS = 4 // source: own choice, fewer steps than this is too small a sample for a failure rate
14export const RECENT_FLAGGED = 8 // source: own choice, the flagged delegations the panel can list
15
16const FLAGS: readonly DelegationFlag[] = ['undesigned', 'rerouted', 'overkill', 'underpowered']
17
18const cap = (s: string): string | null => {
19 const t = s.trim().slice(0, DESIGN_FIELD_CAP)
20 return t === '' ? null : t
21}
22
23// The DESIGN block of a prompt: the lines after the first line that is exactly `DESIGN`, up to the
24// first blank line. `model: X` and `effort: Y` may share a line, separated by ` · `; `genius shape:`
25// takes the rest of its line. A field the block does not state is null; no block is null. Total.
26export const parseDesign = (prompt: string): Design | null => {
27 if (typeof prompt !== 'string') return null
28 const lines = prompt.split(/\r?\n/)
29 const start = lines.findIndex((l) => l.trim() === 'DESIGN')
30 if (start < 0) return null
31 const design: Design = { model: null, effort: null, shape: null }
32 for (const raw of lines.slice(start + 1)) {
33 const line = raw.trim()
34 if (line === '') break
35 const shape = /^genius shape:(.*)$/i.exec(line)
36 if (shape !== null) {
37 design.shape ??= cap(shape[1] ?? '')
38 continue
39 }
40 if (/^(task|check):/i.test(line)) continue
41 for (const part of line.split(' · ')) {
42 const m = /^(model|effort):(.*)$/i.exec(part.trim())
43 if (m === null) continue
44 const key = m[1]?.toLowerCase() === 'model' ? 'model' : 'effort'
45 design[key] ??= cap(m[2] ?? '')
46 }
47 }
48 return design
49}
50
51const REAL = new Set(['haiku', 'sonnet', 'opus', 'fable'])
52
53// The four predicates, the only place each concept is defined:
54// - undesigned: the prompt carried no DESIGN block (design === null). A record kept before the
55// design was (design absent) is legacy: no flag.
56// - rerouted: the design names a model of a real family, the model that ran is of a real family,
57// and the two families differ (the autopilot may lower a model under quota pressure).
58// - overkill: the model that ran is opus or fable and the agent took at most OVERKILL_STEPS steps.
59// - underpowered: the model that ran is haiku, it took at least UNDERPOWERED_MIN_STEPS steps and
60// errors / steps >= RAISE_RATE (the rate learn.ts raises a phase's effort at).
61export const flagsOf = (r: AgentRecord): DelegationFlag[] => {
62 const flags: DelegationFlag[] = []
63 const ran = familyOf(r.model)
64 if (r.design === null) flags.push('undesigned')
65 const wanted = r.design?.model == null ? null : familyOf(r.design.model)
66 if (wanted !== null && REAL.has(wanted) && REAL.has(ran) && wanted !== ran) flags.push('rerouted')
67 if ((ran === 'opus' || ran === 'fable') && r.steps <= OVERKILL_STEPS) flags.push('overkill')
68 if (ran === 'haiku' && r.steps >= UNDERPOWERED_MIN_STEPS && r.errors / r.steps >= RAISE_RATE) flags.push('underpowered')
69 return flags
70}
71
72export const emptyDelegations = (): DelegationStats => ({
73 total: 0,
74 designed: 0,
75 outcomes: { achieved: 0, 'not-achieved': 0, unclear: 0 },
76 rounds: 0,
77 flags: { overkill: 0, underpowered: 0, undesigned: 0, rerouted: 0 },
78 recent: [],
79})
80
81// One delegation is one agentId: a resumed agent writes a record per turn, summed here and judged
82// once. A record without agentId (legacy) is its own delegation.
83type Delegation = { agent: AgentRecord; at: number; session: string }
84
85const delegationsOf = (records: readonly TurnRecord[]): Delegation[] => {
86 const out: Delegation[] = []
87 const byId = new Map<string, Delegation>()
88 for (const rec of records) {
89 for (const a of rec.agents) {
90 const seen = a.agentId === undefined ? undefined : byId.get(a.agentId)
91 if (seen === undefined) {
92 const d = { agent: { ...a }, at: rec.at, session: rec.session }
93 out.push(d)
94 if (a.agentId !== undefined) byId.set(a.agentId, d)
95 continue
96 }
97 const s = seen.agent
98 s.steps += a.steps
99 s.errors += a.errors
100 if (a.tokensIn !== undefined) s.tokensIn = (s.tokensIn ?? 0) + a.tokensIn
101 if (a.tokensOut !== undefined) s.tokensOut = (s.tokensOut ?? 0) + a.tokensOut
102 if (s.design === undefined && a.design !== undefined) s.design = a.design
103 s.model = a.model
104 seen.at = rec.at
105 }
106 }
107 return out
108}
109
110// Every delegation: `total` counts those that have a design field (null included), `designed` those
111// whose design is not null, `flags` each flag over the delegation's summed steps and errors, `recent`
112// the last RECENT_FLAGGED flagged rows, newest last. O(agents).
113export const delegationStats = (records: readonly TurnRecord[]): DelegationStats => {
114 const stats = emptyDelegations()
115 const events = eventsBySession(records)
116 for (const { agent: a, at, session } of delegationsOf(records)) {
117 if (a.design === undefined) continue
118 stats.total += 1
119 if (a.design !== null) stats.designed += 1
120 // The outcome the session's events show for this delegation; a legacy record (no agentId) has none to join.
121 const joined = events.get(session)
122 const judged = a.agentId === undefined ? undefined : judge(joined?.own ?? [], a.agentId, joined?.foreign)
123 if (judged !== undefined) {
124 stats.outcomes[judged.verdict] += 1
125 stats.rounds += judged.rounds
126 } else stats.outcomes.unclear += 1
127 const flags = flagsOf(a)
128 if (flags.length === 0) continue
129 for (const f of FLAGS) if (flags.includes(f)) stats.flags[f] += 1
130 const row: DelegationRow = {
131 at,
132 type: a.type,
133 model: a.model,
134 effort: a.design?.effort ?? null,
135 shape: a.design?.shape ?? null,
136 steps: a.steps,
137 errors: a.errors,
138 flags,
139 ...(judged === undefined ? {} : { outcome: judged.verdict }),
140 }
141 stats.recent.push(row)
142 if (stats.recent.length > RECENT_FLAGGED) stats.recent.shift()
143 }
144 return stats
145}
146hooks/handover.ts 233 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { InsightsState, TurnRecord } from '../types'
5import { windowOf, windowSessions } from './brief'
6import { type RememberOutcome, aggregateSummary, rememberOutcome } from './consumer'
7import { FRICTION_MAX_BYTES, type Unavailable, frictionsOf, recurringOver } from './friction'
8import {
9 AGGREGATE,
10 CLAIM,
11 type Claim,
12 ENDED,
13 FRICTIONS,
14 type Layout,
15 PUSHED,
16 type Pushed,
17 aggregateOf,
18 claimedByOther,
19 frictionTargets,
20 frictionsBySession,
21 handoverPlan,
22 keyOf,
23 newestAt,
24} from './ledger'
25import { probesOf, withoutProbes } from './probes'
26import { cut, recordsOf, INITIAL } from './shared'
27import { note, readLayout } from './store-io'
28
29const insights = atom({ plugin: 'zetetic-insights', key: 'insights' } as const, INITIAL)
30
31// The handoff to Cortex: one aggregate memory over the finished sessions, replacing itself. What it
32// covers lives in the store (it survives restarts; $.state is reset by /clear), one pushed:<sid> key
33// per session, and its head in the key `aggregate`. An update never runs in a hook that ends the
34// session (session.end has about 1.5 s in all, a Cortex remember was measured at 7.8 s): it is
35// scheduled at start, for the sessions finished by then.
36export const PUSH_DELAY_MS = 30_000 // source: own choice, the wait after start before the first remember; the session's own startup work goes first
37const REMEMBER_TAGS = ['archival', 'self-calibration', 'zetetic-insights'] // source: Cortex remember convention (agent-guidance: archival + agent_topic)
38
39// source: Cortex mcp_server/shared/write_class.py classify_write_class (source 'tool' already resolves to deliberate, ADR-0688) and mcp_server/core/write_gate.py determine_bypass (deliberate bypasses the write gate, ADR-0319): stated, not left to the default
40const REMEMBER_WRITE_CLASS = 'deliberate'
41const REMEMBER_SOURCE = 'tool' // source: Cortex mcp_server/handlers/remember_schema.py lines 205-226 (ADR-0442), `source` is an enum (session, tool, user, consolidation, import, distillation); the writer is named by origin_tool
42
43let server = 'plugin_hypermnesia-mcp_cortex' // source: cortex-cockpit plugin.json userConfig cortex_server default
44let generation = 0
45let pushing = false
46
47const cortexOf = (pushed: number, pending: number, lastError: string | null, lastAt: number | null) => ({ pushed, pending, lastError, lastAt })
48const AGGREGATE_CLAIM = keyOf(CLAIM, AGGREGATE)
49
50async function setCortex($: EngineInterface, c: InsightsState['cortex']): Promise<void> {
51 await update($, insights, (s) => ({ ...s, cortex: c }))
52}
53
54// The finished sessions marked as in the aggregate, each up to its newest record as read now.
55async function cover($: EngineInterface, view: Layout, sessions: readonly string[], at: number): Promise<void> {
56 for (const s of sessions) await $.store.set(keyOf(PUSHED, s), { at, lastAt: newestAt(view.turns.get(s) ?? []) } satisfies Pushed)
57}
58
59// What one reply changes (consumer.ts rememberOutcome), and what is named in state.cortex. A merged
60// id is another memory, never recorded; a failure changes nothing and the next start retries.
61async function applyOutcome($: EngineInterface, view: Layout, finished: readonly string[], outcome: RememberOutcome): Promise<string | null> {
62 const at = await $.clock.now()
63 if (outcome.kind === 'failed') return outcome.failure
64 if (outcome.kind === 'merged') return `Cortex merged the aggregate into memory ${outcome.memoryId ?? '?'}, not ours: not recorded`
65 if (outcome.kind === 'rejected') return `not stored, rejected by Cortex: ${outcome.reason}; retried at the next start`
66 if (outcome.kind === 'cleared') {
67 await $.store.set(AGGREGATE, { at }) // the head is only ever an id we created: a foreign one is never adopted
68 return `${outcome.reason}: head cleared${outcome.foreignId === null ? '' : ` (Cortex named memory ${outcome.foreignId}, not ours)`}, the next start remembers a fresh head`
69 }
70 await $.store.set(AGGREGATE, { at, memoryId: outcome.memoryId })
71 await cover($, view, finished, at)
72 return null
73}
74
75// The aggregate updated once, after the delay: only while the claim is still exactly this one, over
76// the store as read now (a session resumed during the delay is live again, and left out).
77async function updateAggregate($: EngineInterface, mine: number, sid: string, claimAt: number): Promise<void> {
78 if (pushing || mine !== generation) return
79 pushing = true
80 try {
81 const claim = (await $.store.get(AGGREGATE_CLAIM)) as Claim | undefined
82 if (claim?.by !== sid || claim.at !== claimAt) return
83 const now = await $.clock.now()
84 const view = await readLayout($, await $.store.keys())
85 const plan = handoverPlan(view, sid, now)
86 if (plan.pending.length === 0) return
87 const decided = probesOf(recordsOf(view), view.transcripts)
88 const content = aggregateSummary(withoutProbes(plan.finished.flatMap((s) => view.turns.get(s) ?? []), decided), now, frictionsBySession(view))
89 let error: string | null = null
90 if (content === null) await cover($, view, plan.finished, now) // nothing measured: nothing to say
91 else {
92 const head = aggregateOf(await $.store.get(AGGREGATE))
93 const args = { content, tags: REMEMBER_TAGS, agent_topic: 'self-calibration', source: REMEMBER_SOURCE, origin_tool: 'zetetic-insights', write_class: REMEMBER_WRITE_CLASS, ...(head.memoryId === undefined ? {} : { supersedes_id: head.memoryId }) }
94 let outcome: RememberOutcome
95 try {
96 outcome = rememberOutcome(await $.mcp.call(server, 'remember', args))
97 } catch (error) {
98 outcome = { kind: 'failed', failure: cut(error) }
99 }
100 error = await applyOutcome($, view, plan.finished, outcome)
101 }
102 const after = handoverPlan(await readLayout($, await $.store.keys()), sid, now)
103 await setCortex($, cortexOf(after.finished.length - after.pending.length, after.pending.length, error?.slice(0, 120) ?? null, await $.clock.now()))
104 } finally {
105 pushing = false
106 }
107}
108
109// I3, at most one process updates the aggregate per claim window. claim:aggregate is re-read from the
110// store just before it is written (never from the start snapshot), skipped when another process holds
111// it within CLAIM_TTL_MS, written, then read back after PUSH_DELAY_MS: only a claim still exactly its
112// own goes on. Two processes both reach their remember only when one stalled PUSH_DELAY_MS (30 s) or
113// more between its re-read and its write: otherwise the later write lands within 30 s of the
114// earlier one, so the earlier one reads it back and skips, or the later one re-read the earlier
115// claim and never wrote.
116async function claimAggregate($: EngineInterface, sid: string, now: number): Promise<boolean> {
117 if (claimedByOther((await $.store.get(AGGREGATE_CLAIM)) as Claim | undefined, sid, now)) return false
118 await $.store.set(AGGREGATE_CLAIM, { by: sid, at: now } satisfies Claim)
119 return true
120}
121
122// The deferred work of one start, in this order: the frictions of the finished sessions are measured
123// first, so the aggregate (when this process holds its claim) says what they found. Neither runs in a
124// hook; each names its own failure and never stops the other.
125type Deferred = { mine: number; sid: string; claimAt: number; claimed: boolean; counts: { pushed: number; pending: number } }
126
127async function afterDelay($: EngineInterface, d: Deferred): Promise<void> {
128 await measureFrictions($, d.mine, d.sid)
129 if (!d.claimed) return
130 await updateAggregate($, d.mine, d.sid, d.claimAt).catch((error) => setCortex($, cortexOf(d.counts.pushed, d.counts.pending, cut(error), null)))
131}
132
133// The Cortex server the aggregate is remembered to (the mod's cortex_server option).
134export const setServer = (name: string): void => {
135 server = name
136}
137
138// Exactly once per change holds when no process stalls PUSH_DELAY_MS between its claim re-read and
139// write, an update ends within CLAIM_TTL_MS of its claim, and no process crashes between remember and
140// its keys (then the next start repeats the remember: at least once). A throw here (the session id,
141// the clock, the store, the state) is named in state.cortex and never fails start(); nothing the
142// timer runs can leave a rejection unhandled.
143export async function schedulePush($: EngineInterface, layout: Layout | null, loadError: string | null): Promise<void> {
144 generation += 1
145 const mine = generation
146 try {
147 const sid = await $.session.id()
148 const now = await $.clock.now()
149 const plan = layout === null ? { finished: [], pending: [] } : handoverPlan(layout, sid, now)
150 const counts = { pushed: plan.finished.length - plan.pending.length, pending: plan.pending.length }
151 await setCortex($, cortexOf(counts.pushed, counts.pending, layout === null ? (loadError ?? 'the ledger did not load') : null, null))
152 const claimed = plan.pending.length > 0 && (await claimAggregate($, sid, now))
153 const toMeasure = layout === null ? [] : frictionTargets(layout, windowSessions(windowOf(recordsOf(layout)).records), sid)
154 if (!claimed && toMeasure.length === 0) return
155 $.clock.after(PUSH_DELAY_MS, () => {
156 afterDelay($, { mine, sid, claimAt: now, claimed, counts })
157 .catch(() => undefined) // the state itself refused: there is nothing left to name it in
158 })
159 } catch (error) {
160 try {
161 await setCortex($, cortexOf(0, 0, `handover not scheduled: ${cut(error)}`.slice(0, 120), null))
162 } catch {
163 // the state itself refused: start() still completes, the next start schedules again
164 }
165 }
166}
167
168const FRICTION_NOT_MEASURED = 'frictions not measured'
169
170// The frictions recurring over the sessions of the brief's window (the own, live one is not measured).
171export const recurringIn = (view: Layout | null, records: readonly TurnRecord[], ownSid: string | null): InsightsState['frictions'] =>
172 view === null ? [] : recurringOver(frictionsBySession(view), [...windowSessions(windowOf(withoutProbes(records, probesOf(records, view.transcripts))).records)].filter((s) => s !== ownSid))
173
174// Reads the transcripts of at most FRICTION_BATCH finished sessions of the window, each at most once:
175// frictionsOf is a pure function of the file, so frictions:<s> holds the same value whichever process
176// writes it (ledger.ts, the exception to I1). A transcript that cannot be read is named in the state
177// and left unmarked, so the next start tries again. Only ever called from the deferred timer.
178async function measureFrictions($: EngineInterface, mine: number, sid: string): Promise<void> {
179 if (mine !== generation) return
180 try {
181 const view = await readLayout($, await $.store.keys())
182 const failed: string[] = []
183 const large: string[] = []
184 for (const t of frictionTargets(view, windowSessions(windowOf(recordsOf(view)).records), sid)) {
185 try {
186 // The session may resume while its transcript is read (its process deletes ended: and the
187 // frictions: it measured): nothing is written unless ended:<s> is exactly what it was when
188 // the measurement began, so a resumed session is measured afresh when it ends again.
189 const endedKey = keyOf(ENDED, t.session)
190 const began = JSON.stringify(await $.store.get(endedKey)) ?? ''
191 // The pre-check and the set are two awaits apart: a resume that runs entirely between them deletes ended: and
192 // frictions:, and the set would then land stale. So after the set the ended: is read again, and if it is absent
193 // or changed the frictions: just written is deleted. This bounds the stale window to the moments between the
194 // set and that delete; the next resume deletion or the next measurement corrects it.
195 const stillEnded = async (): Promise<boolean> => began !== '' && JSON.stringify(await $.store.get(endedKey)) === began
196 const write = async (value: unknown): Promise<void> => {
197 if (!(await stillEnded())) return
198 await $.store.set(keyOf(FRICTIONS, t.session), value)
199 if (!(await stillEnded())) await $.store.delete(keyOf(FRICTIONS, t.session))
200 }
201 if (!(await $.fs.exists(t.transcript))) await write({ unavailable: 'missing' } satisfies Unavailable) // gone for good: a fact, the same for every writer
202 else {
203 const { size } = await $.fs.stat(t.transcript)
204 if (size > FRICTION_MAX_BYTES) {
205 await write({ unavailable: 'too-large' } satisfies Unavailable)
206 large.push(`${t.session.slice(0, 8)} ${size} bytes`)
207 } else await write(frictionsOf(await $.fs.read(t.transcript)))
208 }
209 } catch (error) {
210 failed.push(`${t.session.slice(0, 8)}: ${cut(error)}`)
211 }
212 }
213 const named = [
214 ...(failed.length === 0 ? [] : [`${FRICTION_NOT_MEASURED}, retried at the next start: ${failed.join('; ')}`]),
215 ...(large.length === 0 ? [] : [`${FRICTION_NOT_MEASURED}, transcript over ${FRICTION_MAX_BYTES} bytes: ${large.join('; ')}`]),
216 ]
217 note.frictionError = named.length === 0 ? null : named.join(' | ').slice(0, 240)
218 const after = await readLayout($, await $.store.keys())
219 await update($, insights, (s) => ({
220 ...s,
221 frictions: recurringIn(after, recordsOf(after), sid),
222 ledgerError: note.frictionError ?? (s.ledgerError?.startsWith(FRICTION_NOT_MEASURED) === true ? null : s.ledgerError),
223 }))
224 } catch (error) {
225 note.frictionError = `${FRICTION_NOT_MEASURED}: ${cut(error)}`
226 try {
227 await update($, insights, (s) => ({ ...s, ledgerError: note.frictionError }))
228 } catch {
229 // the state itself refused: there is nothing left to name it in, and the aggregate must still run
230 }
231 }
232}
233hooks/learn.ts 221 lines1// Pure side of the learning loop: the turn records the ledger keeps, what they say per phase and
2// effort, the session outcomes /insights wrote, the goals' status, and the bounded change of one
3// phase's effort the evidence supports.
4
5import type { AgentRecord, AgentStat, Calibration, Effort, Phase, PhaseStat, Proposal, TurnRecord } from '../types'
6
7export const LEDGER_CAP = 3000 // source: own choice, the turns the rates are read from
8// source: claude-code types, `$.store.set` "rejects ... a store over 4 MiB of JSON text in all"
9// (every key of the mod's store). The ledger, all turns:* keys together (ledger.ts), keeps 3 MiB of
10// it as the file writes it, the rest is margin.
11export const LEDGER_BYTES = 3 * 1024 * 1024
12// source: own choice, the agents one record keeps (~200 bytes each); the rest are counted.
13export const MAX_AGENTS = 50
14export const FACET_SESSIONS_CAP = 80 // source: own choice, the facets read at start are the last sessions'
15
16// source: Claude Code 2.1.299 binary: `/insights` writes `<config dir>/usage-data/facets/<session id>.json`
17// with `"outcome": "fully_achieved|mostly_achieved|partially_achieved|not_achieved|unclear_from_transcript"`.
18export const FACETS_DIR = '~/.claude/usage-data/facets'
19// The file the autopilot reads (its hooks/calibration.ts names the same path).
20export const CALIBRATION_PATH = '~/.claude/zetetic-insights/calibration.json'
21// source: zetetic-loop verify-goal skill: a goal file's last `status:` line is its state.
22export const GOALS_DIR = '.zetetic/goals'
23
24// source: own choices, stated so they can be argued with. A phase needs this many main steps at
25// one effort before its rate means anything; a raise needs this share of steps with a failed tool
26// call; a lowering needs almost none, and session outcomes that say the work was achieved.
27export const MIN_STEPS = 40
28// source: own choice, undoing a phase's last move needs three times the evidence that made it,
29// so enforce cannot swing a phase back and forth on runs that sit near both thresholds.
30export const REVERSAL_STEPS = 3 * MIN_STEPS
31export const RAISE_RATE = 0.25
32export const LOWER_RATE = 0.05
33export const MIN_JUDGED_SESSIONS = 5
34export const ACHIEVED_SHARE = 0.8
35
36// source: zetetic-autopilot policy.ts effortFor: without calibration the tool loop runs at medium.
37export const LOOP_DEFAULT: Effort = 'medium'
38const LADDER: readonly Effort[] = ['low', 'medium', 'high']
39
40export type Outcome = 'achieved' | 'not-achieved' | 'unclear'
41
42export const outcomeOf = (facetJson: string): Outcome => {
43 let raw: unknown
44 try {
45 raw = JSON.parse(facetJson)
46 } catch (error) {
47 throw new Error(`facet is not valid JSON (${error instanceof Error ? error.message : String(error)})`.slice(0, 120))
48 }
49 const o = (raw as { outcome?: unknown } | null)?.outcome
50 if (o === 'fully_achieved' || o === 'mostly_achieved') return 'achieved'
51 if (o === 'partially_achieved' || o === 'not_achieved') return 'not-achieved'
52 return 'unclear'
53}
54
55export const goalStatus = (md: string): string => {
56 const lines = md.split('\n').filter((l) => /^status:\s*\S/.test(l))
57 return (lines[lines.length - 1] ?? 'status: none').replace(/^status:\s*/, '').trim()
58}
59
60// UTF-8 length of a JSON text, what the store measures; a lone surrogate counts as 3 bytes, the
61// replacement character it is written as.
62export const utf8Bytes = (text: string): number => {
63 let n = 0
64 for (let i = 0; i < text.length; i++) {
65 const c = text.charCodeAt(i)
66 if (c < 0x80) n += 1
67 else if (c < 0x800) n += 2
68 else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length && (text.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
69 n += 4
70 i++
71 } else n += 3
72 }
73 return n
74}
75
76// A record's bytes as the store file holds it: the file is JSON indented by two spaces (read from
77// ~/.claude/plugins/store/zetetic-insights_*.json, 2026-10-14: 865 bytes against 474 compact), a
78// record sitting two levels deep under `{"turns": [...]}`. Whether the store's 4 MiB counts that
79// text or the compact one, this is the larger, so the cap holds under both.
80export const recordBytes = (r: TurnRecord): number => {
81 const text = JSON.stringify(r, null, 2)
82 let lines = 1
83 for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) lines++
84 return utf8Bytes(text) + 4 * lines + 2 // the indent of each line, then ",\n"
85}
86
87// The newest records that fit both caps, oldest dropped first. Each record is measured once:
88// O(n) in the records, whatever the ledger loaded from the store weighed.
89export const trimmed = (records: readonly TurnRecord[], maxBytes = LEDGER_BYTES, maxCount = LEDGER_CAP): TurnRecord[] => {
90 const sizes = records.map(recordBytes)
91 let total = sizes.reduce((a, b) => a + b, 1)
92 let from = Math.max(0, records.length - maxCount)
93 for (let i = 0; i < from; i++) total -= sizes[i]!
94 while (from < records.length && total > maxBytes) total -= sizes[from++]!
95 return records.slice(from)
96}
97
98// One finished agent added to a turn's list, past MAX_AGENTS counted instead of kept.
99export const withAgent = (
100 list: { agents: AgentRecord[]; dropped: number },
101 agent: AgentRecord,
102): { agents: AgentRecord[]; dropped: number } =>
103 list.agents.length < MAX_AGENTS ? { agents: [...list.agents, agent], dropped: list.dropped } : { agents: list.agents, dropped: list.dropped + 1 }
104
105const statKey = (phase: string, effort: string) => `${phase}|${effort}`
106
107// Main's runs per phase and effort, with the sessions they came from and those sessions' outcomes.
108// A phase moved by the calibration counts only the runs recorded after its last move: the runs
109// before it were made at the effort it left.
110export const phaseStats = (
111 records: readonly TurnRecord[],
112 outcomes: ReadonlyMap<string, Outcome>,
113 changes: Calibration['changes'] = {},
114): PhaseStat[] => {
115 const by = new Map<string, PhaseStat & { sessionSet: Set<string> }>()
116 for (const r of records)
117 for (const run of r.main) {
118 if (r.at <= (changes[run.phase]?.at ?? Number.NEGATIVE_INFINITY)) continue
119 const effort = run.effort ?? 'default'
120 const k = statKey(run.phase, effort)
121 const row = by.get(k) ?? { phase: run.phase, effort, steps: 0, errors: 0, sessions: 0, judged: 0, achieved: 0, sessionSet: new Set<string>() }
122 row.steps += run.steps
123 row.errors += run.errors
124 row.sessionSet.add(r.session)
125 by.set(k, row)
126 }
127 return [...by.values()]
128 .map(({ sessionSet, ...row }) => {
129 const judged = [...sessionSet].map((s) => outcomes.get(s)).filter((o) => o === 'achieved' || o === 'not-achieved')
130 return { ...row, sessions: sessionSet.size, judged: judged.length, achieved: judged.filter((o) => o === 'achieved').length }
131 })
132 .sort((a, b) => a.phase.localeCompare(b.phase) || a.effort.localeCompare(b.effort))
133}
134
135export const agentStats = (records: readonly TurnRecord[]): AgentStat[] => {
136 const by = new Map<string, AgentStat>()
137 for (const r of records)
138 for (const a of r.agents) {
139 const row = by.get(a.type) ?? { type: a.type, runs: 0, steps: 0, errors: 0, models: [] }
140 by.set(a.type, {
141 ...row,
142 runs: row.runs + 1,
143 steps: row.steps + a.steps,
144 errors: row.errors + a.errors,
145 models: row.models.includes(a.model) ? row.models : [...row.models, a.model],
146 })
147 }
148 return [...by.values()].sort((a, b) => b.runs - a.runs)
149}
150
151const notch = (effort: Effort, by: 1 | -1): Effort | undefined => LADDER[LADDER.indexOf(effort) + by]
152
153// One proposal per phase at most: one notch, from the effort the loop runs that phase at now, read
154// from the steps that ran at that effort. The rung is a ceiling (autopilot policy.ts effortFor): a
155// raise lifts only the turns whose graded base is at or above the new rung, a routine turn's loop
156// stays at its base. Undoing the phase's last move needs REVERSAL_STEPS.
157export const proposals = (stats: readonly PhaseStat[], calibration: Calibration): Proposal[] => {
158 const out: Proposal[] = []
159 for (const phase of new Set(stats.map((s) => s.phase as Phase))) {
160 const now = calibration.phases[phase] ?? LOOP_DEFAULT
161 const row = stats.find((s) => s.phase === phase && s.effort === now)
162 if (row === undefined || row.steps < MIN_STEPS) continue
163 const rate = row.errors / row.steps
164 const evidence = `${row.errors} failed calls in ${row.steps} steps at ${now} over ${row.sessions} sessions`
165 const isEnough = (to: Effort) => calibration.changes[phase]?.from !== to || row.steps >= REVERSAL_STEPS
166 const up = notch(now, 1)
167 if (rate >= RAISE_RATE && up !== undefined) {
168 if (isEnough(up)) out.push({ phase, from: now, to: up, evidence })
169 continue
170 }
171 const down = notch(now, -1)
172 const isAchieved = row.judged >= MIN_JUDGED_SESSIONS && row.achieved / row.judged >= ACHIEVED_SHARE
173 if (rate <= LOWER_RATE && isAchieved && down !== undefined && isEnough(down))
174 out.push({ phase, from: now, to: down, evidence: `${evidence}; ${row.achieved}/${row.judged} sessions achieved (/insights)` })
175 }
176 return out
177}
178
179// Phases with enough steps, none of them at the rung the calibration reads: no proposal can come
180// from them (the autopilot observes, or the turns' grades keep the loop elsewhere). Named, so an
181// empty proposal list is not read as "the evidence supports the rung".
182export const offRung = (stats: readonly PhaseStat[], calibration: Calibration): { phase: string; now: Effort; ran: string[] }[] =>
183 [...new Set(stats.map((s) => s.phase as Phase))].flatMap((phase) => {
184 const now = calibration.phases[phase] ?? LOOP_DEFAULT
185 const rows = stats.filter((s) => s.phase === phase)
186 const atNow = rows.find((s) => s.effort === now)?.steps ?? 0
187 const total = rows.reduce((n, s) => n + s.steps, 0)
188 return atNow < MIN_STEPS && total >= MIN_STEPS ? [{ phase, now, ran: rows.map((s) => `${s.effort} ${s.steps}`) }] : []
189 })
190
191export const emptyCalibration = (): Calibration => ({ version: 1, phases: {}, evidence: {}, changes: {}, updatedAt: null })
192
193export const parseCalibration = (text: string): Calibration => {
194 const raw = JSON.parse(text) as Partial<Calibration>
195 if (raw.version !== 1 || typeof raw.phases !== 'object' || raw.phases === null)
196 throw new Error('calibration.json is not version 1 with a phases object')
197 return { version: 1, phases: raw.phases, evidence: raw.evidence ?? {}, changes: raw.changes ?? {}, updatedAt: raw.updatedAt ?? null }
198}
199
200export const applied = (c: Calibration, ps: readonly Proposal[], at: number): Calibration => ({
201 version: 1,
202 phases: { ...c.phases, ...Object.fromEntries(ps.map((p) => [p.phase, p.to])) },
203 evidence: { ...c.evidence, ...Object.fromEntries(ps.map((p) => [p.phase, `${p.from} → ${p.to}: ${p.evidence}`])) },
204 changes: { ...c.changes, ...Object.fromEntries(ps.map((p) => [p.phase, { at, from: p.from, to: p.to }])) },
205 updatedAt: at,
206})
207
208// The advisor against the grades: how often an analysis or critical turn consulted it before
209// writing, and how often a routine one called it at all.
210export const advisorStats = (records: readonly TurnRecord[]) => {
211 const heavy = records.filter((r) => r.taskClass === 'analysis' || r.taskClass === 'critical')
212 const light = records.filter((r) => r.taskClass === 'routine' || r.taskClass === 'planned')
213 return {
214 heavyTurns: heavy.length,
215 heavyBeforeWrite: heavy.filter((r) => r.advisor.beforeWrite > 0).length,
216 heavyAtCompletion: heavy.filter((r) => r.advisor.atCompletion > 0).length,
217 lightTurns: light.length,
218 lightWithAdvisor: light.filter((r) => r.advisor.calls > 0).length,
219 }
220}
221hooks/ledger.ts 225 lines1// The ledger's layout in the mod's store, one key per owner; pure. `$.store` is ONE JSON file
2// shared by every Claude process: writes to different keys merge, a read-modify-write of one shared
3// key was not shown safe (claude-mods/.claude/evidence/store-concurrency-2026-10-14.md). So each
4// process writes only keys it owns, and nothing reads, modifies and writes back a shared key.
5// I1: turns:<s>, ended:<s> and transcript:<s> are written by the process of session s only. Another process writes
6// only pushed:<s> of the finished sessions an aggregate update covered and aggregate/claim:aggregate
7// (I3), legacy:<s> and an absent pushed:<s> in the migration (I5), and deletes the keys of ended
8// sessions (I2) and orphans.
9// I2: a process deletes another session's keys only when that session has ended:<s> (register.tsx
10// saved states the one race left). Never a session without ended:, idle or crashed.
11// I3: the aggregate is updated by at most one process per claim window (register.tsx claimAggregate).
12// I4: the own write lands within LEDGER_BYTES, or is refused and named in ledgerError.
13// I5: the migration is idempotent and writes no key an owner writes.
14// The exception to I1, frictions:<s>: any process may write it, for any finished session whose ended:<s>
15// and transcript:<s> exist. Its value is the pure function frictionsOf of that file (no clock, no writer
16// in it, a total order on its rows), so two processes writing at once write the same value. The
17// process of s deletes it when s resumes (its transcript grows again); eviction and orphans delete
18// it as the other keys of s.
19
20import type { TurnRecord } from '../types'
21import { type Friction, isUnavailable, liveRowsOf, mergeFrictions, rowsOf } from './friction'
22import { LEDGER_BYTES, recordBytes, trimmed, utf8Bytes } from './learn'
23
24export const TURNS = 'turns:' // that session's records; only the process whose session id it is writes it
25export const LEGACY = 'legacy:' // that session's records moved from the legacy key; only the migration writes it
26export const ENDED = 'ended:' // { at }, set by that same process at session.end (also fired by /clear, old id); a value of 0.7.0 builds before the split may carry a transcript, honoured as legacy
27export const TRANSCRIPT = 'transcript:' // { path }, set by that same process at classic.SessionEnd and by nothing else: no hook's order is relied on, each hook writes its own key
28export const CLAIM = 'claim:' // { by, at }, set by the process about to hand the session over, then read back
29export const PUSHED = 'pushed:' // { at, lastAt }: that session is in the aggregate memory, up to its record lastAt
30export const AGGREGATE = 'aggregate' // { at, memoryId? }: the head of the one aggregate memory in Cortex; claim:aggregate guards it
31export const FRICTIONS = 'frictions:' // that session's frictions (friction.ts), measured from the transcript its ended: names; [] is measured, none found
32export const SESSION_PREFIXES = [TURNS, LEGACY, ENDED, PUSHED, CLAIM, TRANSCRIPT, FRICTIONS] as const // a deleted session loses all seven, records first
33export const LEGACY_TURNS = 'turns' // the single-key ledger of 0.5-0.6 (PR #13), every process rewriting it whole
34export const LEGACY_PUSHED = 'pushed' // the single-key handover list of PR #18 before this layout
35
36export const CLAIM_TTL_MS = 10 * 60_000 // source: own choice, far above one batch (30 s delay, then 5 remembers at 7.8 s measured 2026-10-14); an older claim is a process that died mid-handover
37export const FRICTION_BATCH = 3 // source: own choice, transcripts read per start; a transcript is megabytes, and the read is deferred (observer effect: the measure must not slow the session it measures)
38export const STALE_MS = 24 * 3_600_000 // source: own choice, a session with no turn for a day is finished for the handover: covers a crashed process that never fired session.end
39
40export type Claim = { by: string; at: number }
41export type Aggregate = { at: number; memoryId?: number }
42export const aggregateOf = (v: unknown): Aggregate => {
43 const o = (typeof v === 'object' && v !== null ? v : {}) as Record<string, unknown>
44 const id = o.memoryId
45 return { at: typeof o.at === 'number' ? o.at : 0, ...(Number.isSafeInteger(id) && (id as number) > 0 ? { memoryId: id as number } : {}) }
46}
47// What a handover recorded: when, and the newest record `at` it covered.
48export type Pushed = { at: number; lastAt: number }
49
50// A pushed: value without a numeric lastAt (the legacy { at: 0 }, or junk) is no handover: the
51// session is sent again, delivery stays at least once.
52export const pushedOf = (v: unknown): Pushed | undefined => {
53 const o = (typeof v === 'object' && v !== null ? v : {}) as Record<string, unknown>
54 return typeof o.lastAt === 'number' && Number.isFinite(o.lastAt) ? { at: typeof o.at === 'number' ? o.at : 0, lastAt: o.lastAt } : undefined
55}
56export const isCovered = (p: Pushed | undefined, newest: number): boolean => p !== undefined && newest <= p.lastAt
57
58export const keyOf = (prefix: string, session: string): string => `${prefix}${session}`
59export const sessionOfKey = (key: string, prefix: string): string | null => (key.startsWith(prefix) && key.length > prefix.length ? key.slice(prefix.length) : null)
60
61// What the store holds, by session: turns:<s> and legacy:<s> together, one record per turnId. A
62// value of either that is not a list is listed in `malformed`.
63export type Layout = {
64 turns: ReadonlyMap<string, readonly TurnRecord[]>
65 ended: ReadonlySet<string>
66 // The transcript each ended session named at its end; a session whose ended: has none (an older
67 // value, or classic hooks not run under managed settings) is not here: its frictions are not measured.
68 transcripts: ReadonlyMap<string, string>
69 frictions: ReadonlyMap<string, readonly Friction[]>
70 pushed: ReadonlyMap<string, Pushed>
71 claims: ReadonlyMap<string, Claim>
72 malformed: readonly string[]
73}
74
75// Two record lists of one session as one, in time order, a turnId kept once.
76export const unionOf = (a: readonly TurnRecord[], b: readonly TurnRecord[]): TurnRecord[] => {
77 const seen = new Set(a.map((r) => r.turnId))
78 return [...a, ...b.filter((r) => !seen.has(r.turnId))].sort((x, y) => x.at - y.at)
79}
80
81const isClaim = (v: unknown): v is Claim => typeof v === 'object' && v !== null && typeof (v as Claim).by === 'string' && typeof (v as Claim).at === 'number'
82
83export const layoutOf = (entries: ReadonlyMap<string, unknown>): Layout => {
84 const turns = new Map<string, readonly TurnRecord[]>()
85 const ended = new Set<string>()
86 const transcripts = new Map<string, string>()
87 const legacyTranscripts = new Map<string, string>()
88 const frictions = new Map<string, readonly Friction[]>()
89 const pushed = new Map<string, Pushed>()
90 const claims = new Map<string, Claim>()
91 const malformed: string[] = []
92 for (const [key, value] of entries) {
93 const t = sessionOfKey(key, TURNS) ?? sessionOfKey(key, LEGACY)
94 if (t !== null) {
95 if (Array.isArray(value)) turns.set(t, unionOf(turns.get(t) ?? [], value as TurnRecord[]))
96 else malformed.push(key)
97 continue
98 }
99 const e = sessionOfKey(key, ENDED)
100 if (e !== null) {
101 ended.add(e)
102 const legacy = typeof value === 'object' && value !== null ? (value as { transcript?: unknown }).transcript : undefined
103 if (typeof legacy === 'string' && legacy !== '') legacyTranscripts.set(e, legacy)
104 }
105 const tr = sessionOfKey(key, TRANSCRIPT)
106 const path = tr === null || typeof value !== 'object' || value === null ? undefined : (value as { path?: unknown }).path
107 if (tr !== null && typeof path === 'string' && path !== '') transcripts.set(tr, path)
108 const f = sessionOfKey(key, FRICTIONS)
109 if (f !== null && (Array.isArray(value) || isUnavailable(value))) frictions.set(f, rowsOf(value)) // an unavailable marker is measured, with no rows
110 const p = sessionOfKey(key, PUSHED)
111 const covered = p === null ? undefined : pushedOf(value)
112 if (p !== null && covered !== undefined) pushed.set(p, covered)
113 const c = sessionOfKey(key, CLAIM)
114 if (c !== null && isClaim(value)) claims.set(c, value)
115 }
116 for (const [s, legacy] of legacyTranscripts) if (!transcripts.has(s)) transcripts.set(s, legacy)
117 return { turns, ended, transcripts, frictions, pushed, claims, malformed }
118}
119
120// Every session's frictions as one view: the live events its records carry (seen at tool.call, no
121// transcript needed) and the transcript's rows when it was measured, the larger count of a row seen
122// by both. A session none of whose records carries `frictions` and with no transcript rows was not
123// measured: it is absent.
124export const frictionsBySession = (layout: Layout): Map<string, readonly Friction[]> => {
125 const out = new Map<string, readonly Friction[]>(layout.frictions)
126 for (const [session, records] of layout.turns) {
127 if (!records.some((r) => r.frictions !== undefined)) continue
128 out.set(session, mergeFrictions(liveRowsOf(records.map((r) => r.frictions)), layout.frictions.get(session) ?? []))
129 }
130 return out
131}
132
133export const newestAt = (records: readonly TurnRecord[]): number => records.reduce((m, r) => Math.max(m, r.at), Number.NEGATIVE_INFINITY)
134
135// Finished for the handover (never for eviction, I2): its process said so at session.end, or it has
136// had no turn for STALE_MS.
137export const isFinished = (session: string, ended: ReadonlySet<string>, newest: number, now: number): boolean => ended.has(session) || now - newest >= STALE_MS
138
139export const claimedByOther = (claim: Claim | undefined, own: string, now: number): boolean => claim !== undefined && claim.by !== own && now - claim.at < CLAIM_TTL_MS
140
141// The handover (I3): the finished sessions other than the own one, oldest first, and those of
142// them whose pushed: does not cover their newest record. The aggregate needs an update when that
143// second list is not empty; every finished session is in its text (register.tsx).
144export const handoverPlan = (layout: Layout, own: string, now: number): { finished: string[]; pending: string[] } => {
145 const finished = [...layout.turns.entries()]
146 .map(([session, records]) => ({ session, newest: newestAt(records) }))
147 .filter((s) => s.session !== own && isFinished(s.session, layout.ended, s.newest, now))
148 .sort((a, b) => a.newest - b.newest)
149 return { finished: finished.map((s) => s.session), pending: finished.filter((s) => !isCovered(layout.pushed.get(s.session), s.newest)).map((s) => s.session) }
150}
151
152// A records key's bytes in the store file: its records as recordBytes measures them (two levels
153// deep, as under the legacy key), plus the key's own line.
154export const keyBytes = (key: string, records: readonly TurnRecord[]): number => utf8Bytes(JSON.stringify(key)) + 8 + records.reduce((n, r) => n + recordBytes(r), 0)
155
156export type Sized = { session: string; bytes: number; newest: number; ended: boolean; pushed: boolean }
157
158// I4 and I2: the ended sessions the own write deletes, pushed (every record covered) first then
159// unpushed, each oldest first, until everything fits. A session without ended: is never chosen.
160// null: even with every ended session gone the own write does not fit, so it is refused and nothing
161// is deleted. An unpushed session evicted here is never handed to Cortex: its records are gone.
162export const evictions = (others: readonly Sized[], ownBytes: number, budget = LEDGER_BYTES): string[] | null => {
163 let rest = others.reduce((n, s) => n + s.bytes, 0)
164 const evict: string[] = []
165 const candidates = others.filter((s) => s.ended).sort((a, b) => (a.pushed === b.pushed ? a.newest - b.newest : a.pushed ? -1 : 1))
166 for (const s of candidates) {
167 if (rest + ownBytes <= budget) break
168 evict.push(s.session)
169 rest -= s.bytes
170 }
171 return rest + ownBytes <= budget ? evict : null
172}
173
174// The own records within the budget alone (and the record count cap), oldest dropped.
175export const ownWithin = (session: string, records: readonly TurnRecord[], room = LEDGER_BYTES): TurnRecord[] => trimmed(records, Math.max(0, room - keyBytes(keyOf(TURNS, session), []) + 1))
176
177// ended:, pushed:, claim:, transcript: and frictions: keys whose session has no records key (evicted while another
178// process claimed, pushed or measured it): deleted at the next own write. Never the own session's, never claim:aggregate.
179export const orphans = (keys: readonly string[], own: string): string[] => {
180 const held = new Set(keys.map((k) => sessionOfKey(k, TURNS) ?? sessionOfKey(k, LEGACY)).filter((s): s is string => s !== null))
181 return keys.filter((k) => {
182 for (const p of [ENDED, PUSHED, CLAIM, TRANSCRIPT, FRICTIONS]) {
183 const s = sessionOfKey(k, p)
184 if (s !== null) return s !== own && s !== AGGREGATE && !held.has(s)
185 }
186 return false
187 })
188}
189
190export type Migration = { sets: [string, unknown][]; deletes: string[]; malformed: boolean }
191
192// I5: legacy:<s> is a function of the legacy key alone, and no owner writes that family, so two
193// processes migrating at once, before or after an owner's write to turns:<s>, write the same value
194// beside it (I1 holds strictly). pushed:<s> is written only when absent: lastAt is the newest record
195// of s under the legacy key or turns:<s>; with no record, nothing. A second run finds nothing left.
196// The legacy ledger is trimmed as the old loader did on every load: what moves is what it kept.
197export const migrationPlan = (legacyTurns: unknown, legacyPushed: unknown, present: ReadonlySet<string>, held: (session: string) => readonly TurnRecord[] | undefined): Migration => {
198 const sets: [string, unknown][] = []
199 const deletes: string[] = []
200 const records = Array.isArray(legacyTurns) ? trimmed(legacyTurns as TurnRecord[]) : []
201 const bySession = new Map<string, TurnRecord[]>()
202 for (const r of records) bySession.set(r.session, [...(bySession.get(r.session) ?? []), r])
203 for (const [session, own] of bySession) if (!present.has(keyOf(LEGACY, session))) sets.push([keyOf(LEGACY, session), own])
204 const ids = Array.isArray(legacyPushed) ? legacyPushed.filter((x): x is string => typeof x === 'string') : []
205 for (const id of new Set(ids)) {
206 const newest = newestAt([...(bySession.get(id) ?? []), ...(held(id) ?? [])])
207 if (present.has(keyOf(PUSHED, id)) || !Number.isFinite(newest)) continue
208 sets.push([keyOf(PUSHED, id), { at: 0, lastAt: newest } satisfies Pushed])
209 }
210 if (legacyTurns !== undefined) deletes.push(LEGACY_TURNS)
211 if (legacyPushed !== undefined) deletes.push(LEGACY_PUSHED)
212 return { sets, deletes, malformed: legacyTurns !== undefined && !Array.isArray(legacyTurns) }
213}
214
215// The sessions of the brief's window to measure, newest first, at most `batch`: ended with a
216// transcript named (transcript:<s>, else a legacy ended: value's), no frictions: yet, not the own session. Without a transcript nothing is measured.
217export const frictionTargets = (layout: Layout, window: ReadonlySet<string>, own: string, batch = FRICTION_BATCH): { session: string; transcript: string }[] =>
218 [...layout.transcripts]
219 .filter(([session]) => session !== own && layout.ended.has(session) && window.has(session) && !layout.frictions.has(session))
220 .map(([session, transcript]) => ({ session, transcript, newest: newestAt(layout.turns.get(session) ?? []) }))
221 .sort((a, b) => b.newest - a.newest || (a.session < b.session ? -1 : 1))
222 .slice(0, batch)
223 .map(({ session, transcript }) => ({ session, transcript }))
224
225hooks/models.ts 59 lines1// Who did the steps: each ledger turn's main steps and its agents' steps, counted under one model
2// family, per session. The one counting procedure the panel shows; pure, so the register and the
3// tests share it.
4
5import type { ModelFamily, SessionModels, TurnRecord } from '../types'
6import type { ProbeInfo } from './probes'
7
8export const MODEL_SESSIONS = 12 // source: own choice, the sessions the panel shows, the newest last
9
10// The families a model id is matched against, in this order: a name carrying two of them is the first.
11const MATCH: readonly Exclude<ModelFamily, 'other' | 'unknown'>[] = ['fable', 'opus', 'sonnet', 'haiku']
12// The order the steps are listed in.
13const ORDER: readonly ModelFamily[] = ['haiku', 'sonnet', 'opus', 'fable', 'other', 'unknown']
14
15// A model id reduced to its family. Empty (or missing, passed as '') is unknown; a name matching
16// no family is other.
17export const familyOf = (model: string): ModelFamily => {
18 if (model === '') return 'unknown'
19 const name = model.toLowerCase()
20 for (const family of MATCH) if (name.includes(family)) return family
21 return 'other'
22}
23
24// Each session's steps by family, the newest MODEL_SESSIONS working sessions by their latest record, oldest
25// first, probes (hooks/probes.ts) flagged and not counted among them. Every main run and every agent step is added to exactly one family, so a session's family
26// steps sum to its main steps plus its agent steps. A main turn with no mainModel (a record kept
27// before it was) is unknown. The sums are O(records); the sessions are sorted by their latest turn.
28export const sessionModels = (records: readonly TurnRecord[], probes: ReadonlyMap<string, ProbeInfo> = new Map()): SessionModels[] => {
29 const by = new Map<string, { at: number; steps: Map<ModelFamily, number> }>()
30 const add = (session: string, at: number, family: ModelFamily, steps: number) => {
31 const row = by.get(session) ?? { at, steps: new Map<ModelFamily, number>() }
32 row.at = Math.max(row.at, at)
33 row.steps.set(family, (row.steps.get(family) ?? 0) + steps)
34 by.set(session, row)
35 }
36 for (const r of records) {
37 const main = familyOf(r.mainModel ?? '')
38 for (const run of r.main) add(r.session, r.at, main, run.steps)
39 for (const a of r.agents) add(r.session, r.at, familyOf(a.model), a.steps)
40 }
41 const rows: SessionModels[] = [...by.entries()]
42 .map(([session, row]) => ({
43 session,
44 at: row.at,
45 steps: ORDER.flatMap((family) => {
46 const steps = row.steps.get(family) ?? 0
47 return steps > 0 ? [{ family, steps }] : []
48 }),
49 probe: probes.get(session)?.probe === true,
50 probeReason: probes.get(session)?.reason ?? null,
51 }))
52 .sort((a, b) => a.at - b.at)
53 // The newest MODEL_SESSIONS working sessions, and the probes newer than the oldest of them: flagged,
54 // never filling a slot of the chart.
55 const work = rows.filter((r) => !r.probe).slice(-MODEL_SESSIONS)
56 const from = work[0]?.at ?? Number.POSITIVE_INFINITY
57 return rows.filter((r) => (r.probe ? r.at >= from : work.includes(r)))
58}
59hooks/observe.ts 59 lines1import type { EngineInterface } from 'claude-code'
2
3import type { LiveFriction, OutcomeEvent } from '../types'
4import { type BashResult, addFriction, eventsOfBash, foldEvent, liveFrictionOf } from './events'
5import { bodyFileOf } from './review'
6import { isUnderTemp } from './probes'
7import { expandHome } from './shared'
8
9// What the turn in progress showed at tool.call, main's and its agents': the outcome events and the
10// frictions, flushed into the turn's record at turn.complete (bounded: MAX_EVENTS, MAX_LIVE_FRICTIONS).
11let events: { events: OutcomeEvent[]; dropped: number } = { events: [], dropped: 0 }
12let frictions: LiveFriction[] = []
13// The origin of this session, stamped on every record: set at session.start (isInteractive, cwd), kept across /clear and /resume.
14let origin: { nonInteractive?: boolean; tmpCwd?: true } = {}
15
16export const setOrigin = (isInteractive: boolean, cwd: string, temps: readonly (string | undefined)[]): void => {
17 origin = {
18 nonInteractive: !isInteractive,
19 ...(isUnderTemp(cwd, temps.filter((t): t is string => typeof t === 'string')) ? { tmpCwd: true as const } : {}),
20 }
21}
22
23export const originStamp = (): { nonInteractive?: boolean; tmpCwd?: true } => origin
24
25// The turn's observations, handed to its record and cleared.
26export const takeObserved = (): { events: OutcomeEvent[]; eventsDropped: number; frictions: LiveFriction[] } => {
27 const out = { events: events.events, eventsDropped: events.dropped, frictions }
28 events = { events: [], dropped: 0 }
29 frictions = []
30 return out
31}
32
33const BODY_FILE_MAX = 64 * 1024 // source: own choice, bytes of a review body file read to find its verdict; a review is a few KB (the reviewer protocol posts one comment per verdict)
34
35// A review body kept in a file, read for its verdict: null when it cannot be read or is over BODY_FILE_MAX.
36async function readBody($: EngineInterface, path: string): Promise<string | null> {
37 try {
38 const full = path.startsWith('/') || path.startsWith('~') ? await expandHome($, path) : `${await $.session.cwd()}/${path}`
39 if (!(await $.fs.exists(full)) || (await $.fs.stat(full)).size > BODY_FILE_MAX) return null
40 return await $.fs.read(full)
41 } catch {
42 return null
43 }
44}
45
46// What one finished tool call shows (events.ts): its outcome events when it is a Bash call, its friction
47// when it errored or was refused. Never throws and never changes the call: a failure here is no event.
48export async function observeCall($: EngineInterface, e: { tool: unknown; agentId?: string; command?: unknown }, ran: BashResult): Promise<void> {
49 const f = liveFrictionOf(String(e.tool), ran)
50 if (f !== null) frictions = addFriction(frictions, f)
51 if (e.tool !== 'Bash' || typeof e.command !== 'string') return
52 const file = bodyFileOf(e.command)
53 const bodyFile = file === null || ran.isError === true || ran.deny !== undefined ? undefined : file.path === null ? null : await readBody($, file.path)
54 const at = await $.clock.now()
55 for (const ev of eventsOfBash({ command: e.command, at, ...(e.agentId === undefined ? {} : { agent: e.agentId }), res: ran, ...(bodyFile === undefined ? {} : { bodyFile }) }))
56 events = foldEvent(events, ev)
57}
58
59hooks/outcome.ts 189 lines1// The outcome model (docs/insights-outcome-model.md): what the outcome events of a session say about
2// a delegation or about the session. Pure. The events are joined at read time over the whole session,
3// not when they are written: the revert, the review verdict and the merge usually come later than
4// the commit or the `gh pr create`, from another agent or the main thread.
5
6import type { OutcomeEvent, OutcomeJudgement, OutcomeSources, TurnRecord, Verdict } from '../types'
7import { prKey, samePr } from './events'
8
9const inTimeOrder = (events: readonly OutcomeEvent[]): OutcomeEvent[] =>
10 events
11 .map((e, i) => ({ e, i }))
12 .sort((a, b) => a.e.at - b.e.at || a.i - b.i)
13 .map(({ e }) => e)
14
15// A call that named a pull request by number alone (`gh pr merge 24`) means the repository the session
16// named it with elsewhere (`o/r#24`), when that is one repository: the key is then qualified, so it can
17// be joined with another session's. A number two repositories share in the session stays bare.
18const qualified = (events: readonly OutcomeEvent[]): OutcomeEvent[] => {
19 const repos = new Map<string, Set<string>>()
20 for (const e of events) {
21 const [repo, n] = (e.pr ?? '').split('#')
22 if (repo !== undefined && repo !== '' && n !== undefined) repos.set(n, (repos.get(n) ?? new Set()).add(repo))
23 }
24 return events.map((e) => {
25 const [repo, n] = (e.pr ?? '').split('#')
26 const only = repo === '' && n !== undefined ? repos.get(n) : undefined
27 return only !== undefined && only.size === 1 ? { ...e, pr: prKey([...only][0]!, n!) } : e
28 })
29}
30
31// A session's events in time order (ties keep their order of writing), pull requests qualified.
32export const eventsOf = (records: readonly TurnRecord[]): OutcomeEvent[] => qualified(inTimeOrder(records.flatMap((r) => r.events ?? [])))
33
34const REVIEW_KINDS: readonly OutcomeEvent['kind'][] = ['verdict', 'pr-merge', 'pr-close']
35
36// Each session's own events, and the review, merge and close events other sessions wrote about the
37// pull requests it names (`owner/repo#N`: a number without its repository joins nothing across sessions).
38// The ledger is bounded by the store trim, so a session whose later events were trimmed away is judged
39// on what is kept. The join is read-time, so a later event re-judges an earlier session.
40export type SessionEvents = { own: OutcomeEvent[]; foreign: OutcomeEvent[] }
41export const eventsBySession = (records: readonly TurnRecord[]): Map<string, SessionEvents> => {
42 const by = new Map<string, TurnRecord[]>()
43 for (const r of records) by.set(r.session, [...(by.get(r.session) ?? []), r])
44 const own = new Map([...by].map(([session, rs]) => [session, eventsOf(rs)] as const))
45 const index = new Map<string, { session: string; e: OutcomeEvent }[]>()
46 for (const [session, events] of own)
47 for (const e of events) if (e.pr !== undefined && REVIEW_KINDS.includes(e.kind)) index.set(e.pr, [...(index.get(e.pr) ?? []), { session, e }])
48 const out = new Map<string, SessionEvents>()
49 for (const [session, events] of own) {
50 const keys = new Set(events.flatMap((e) => (e.pr === undefined || e.pr.startsWith('#') ? [] : [e.pr])))
51 const foreign = [...keys].flatMap((k) => (index.get(k) ?? []).filter((x) => x.session !== session).map((x) => x.e))
52 out.set(session, { own: events, foreign: inTimeOrder(foreign) })
53 }
54 return out
55}
56
57type Commit = { sha: string; at: number; agent: string | undefined; reverted: boolean; discarded: boolean }
58
59const prefixed = (a: string, b: string): boolean => a.startsWith(b) || b.startsWith(a)
60const count = (e: OutcomeEvent): number => e.n ?? 1
61
62// The commits the events show, each marked reverted (`git revert`), or possibly discarded (a
63// `git reset HEAD~n`), in the order they were made. An amend replaces the actor's latest live commit.
64const commitsOf = (events: readonly OutcomeEvent[]): Commit[] => {
65 const commits: Commit[] = []
66 for (const e of events) {
67 if (e.kind === 'commit' && e.ok !== false && e.sha !== undefined) {
68 const old = e.label === 'amended' ? [...commits].reverse().find((c) => c.agent === e.agent && !c.reverted) : undefined
69 if (old !== undefined) commits.splice(commits.indexOf(old), 1) // the old one is not a failure
70 commits.push({ sha: e.sha, at: e.at, agent: e.agent, reverted: false, discarded: false })
71 } else if (e.kind === 'revert' && e.sha !== undefined) {
72 const sha = e.sha
73 const target = sha === 'head' ? [...commits].reverse().find((c) => c.at <= e.at && !c.reverted) : commits.find((c) => prefixed(c.sha, sha))
74 if (target !== undefined) target.reverted = true
75 } else if (e.kind === 'reset' && e.back !== undefined) {
76 // `git reset HEAD~n` may have dropped the n latest live commits; with another target it is not readable which.
77 for (const c of [...commits].reverse().filter((c) => c.at <= e.at && !c.reverted && !c.discarded).slice(0, e.back)) c.discarded = true
78 }
79 }
80 return commits
81}
82
83type PrState = { rounds: number; approved: boolean; stale: boolean; closed: boolean; changes: boolean; unreviewedMerge: boolean; open: boolean }
84
85// What the verdicts, merges and closes say about the scope's pull requests: those it created, those whose
86// verdict cites one of its commits, and (for the whole session) every one reviewed. `foreign` holds the
87// review, merge and close events other sessions wrote about the same `owner/repo#N`. A merge or close
88// counts only when its call proved it (`ok` true).
89const prState = (events: readonly OutcomeEvent[], foreign: readonly OutcomeEvent[], scope: string | null, mine: readonly Commit[]): PrState => {
90 const live = mine.filter((c) => !c.reverted)
91 const lastCommitAt = live.reduce((m, c) => Math.max(m, c.at), Number.NEGATIVE_INFINITY)
92 const created = events.filter((e) => e.kind === 'pr-create' && e.ok !== false && e.pr !== undefined && (scope === null || e.agent === scope)).map((e) => e.pr!)
93 const verdicts = events.filter((e) => e.kind === 'verdict' && e.pr !== undefined)
94 const viaHead = verdicts.filter((v) => v.head !== undefined && mine.some((c) => prefixed(c.sha, v.head!))).map((v) => v.pr!)
95 // One entry per pull request: the same PR is named `o/r#24` by one call and `#24` by another.
96 const prs: string[] = []
97 for (const pr of [...created, ...viaHead, ...(scope === null ? verdicts.map((v) => v.pr!) : [])]) if (!prs.some((q) => samePr(q, pr))) prs.push(pr)
98 const st: PrState = { rounds: 0, approved: false, stale: false, closed: false, changes: false, unreviewedMerge: false, open: false }
99 for (const pr of prs) {
100 const about = inTimeOrder([...events.filter((e) => samePr(e.pr, pr)), ...foreign.filter((e) => e.pr === pr)])
101 const vs = about.filter((e) => e.kind === 'verdict' && e.verdict !== 'unknown')
102 st.rounds += vs.filter((v) => v.verdict === 'changes').reduce((n, v) => n + count(v), 0)
103 const last = vs[vs.length - 1]
104 const merged = about.some((e) => e.kind === 'pr-merge' && e.ok === true)
105 const closed = !merged && about.some((e) => e.kind === 'pr-close' && e.ok === true)
106 if (closed) st.closed = true
107 if (!merged && !closed) {
108 st.open = true
109 if (last?.verdict === 'changes') st.changes = true
110 }
111 if (merged && last?.verdict === 'changes') st.unreviewedMerge = true
112 if (!merged || last?.verdict !== 'approve') continue
113 const headOk = last.head === undefined || live.length === 0 || live.some((c) => prefixed(c.sha, last.head!))
114 if (last.at >= lastCommitAt && headOk) st.approved = true
115 else st.stale = true
116 }
117 return st
118}
119
120type TestState = { red: boolean; green: boolean; passed: number; failed: number }
121
122// The test runs of the scope that could be read: red when the final run of some label failed, green
123// when a label's final run passed after the last commit.
124const testState = (events: readonly OutcomeEvent[], scope: string | null, lastCommitAt: number): TestState => {
125 const tests = events.filter((e) => e.kind === 'test' && (scope === null || e.agent === scope) && e.ok !== null && e.ok !== undefined)
126 const finalByLabel = new Map<string, OutcomeEvent>()
127 for (const t of tests) finalByLabel.set(t.label ?? '', t)
128 const finals = [...finalByLabel.values()]
129 return {
130 red: finals.some((t) => t.ok === false),
131 green: finals.some((t) => t.ok === true && t.at >= lastCommitAt),
132 passed: tests.filter((t) => t.ok === true).reduce((n, t) => n + count(t), 0),
133 failed: tests.filter((t) => t.ok === false).reduce((n, t) => n + count(t), 0),
134 }
135}
136
137// The judgement of `scope`: a delegation's agentId, or null for the whole session (the main thread
138// and every agent). Total.
139export const judge = (events: readonly OutcomeEvent[], scope: string | null, foreign: readonly OutcomeEvent[] = []): OutcomeJudgement => {
140 const mine = commitsOf(events).filter((c) => scope === null || c.agent === scope)
141 const live = mine.filter((c) => !c.reverted)
142 const lastCommitAt = live.reduce((m, c) => Math.max(m, c.at), Number.NEGATIVE_INFINITY)
143 const pr = prState(events, foreign, scope, mine)
144 const t = testState(events, scope, lastCommitAt)
145 const reverted = mine.length - live.length
146 const base = { rounds: pr.rounds, commits: mine.length, reverted, possiblyDiscarded: live.filter((c) => c.discarded).length, testsPassed: t.passed, testsFailed: t.failed }
147 const done = (verdict: Verdict, reason: OutcomeJudgement['reason']): OutcomeJudgement => ({ verdict, reason, ...base })
148 if (reverted > 0) return done('not-achieved', 'reverted')
149 if (pr.approved) return done('achieved', 'merged-approved')
150 if (pr.stale) return done('unclear', 'approve-stale')
151 if (pr.closed) return done('not-achieved', 'closed')
152 // A merge after REQUEST-CHANGES with no later APPROVE: merged, but never reviewed good. Neither side.
153 if (pr.unreviewedMerge) return done('unclear', 'merged-unapproved')
154 // A pull request still open is not a failure: it is waiting for the next review, merge or close.
155 if (pr.changes || (scope !== null && pr.open)) return done('unclear', 'pending')
156 if (t.red && mine.length > 0) return done('not-achieved', 'tests-red')
157 // A delegation is achieved by its PR alone; a session with commits and a green run after the last of them is verified work.
158 if (scope === null && live.length > 0 && t.green && !t.red) return done('achieved', 'verified')
159 return done('unclear', 'no-signal')
160}
161
162// Each session's outcome: the events' judgement when they say (external, observed), else the
163// /insights facet when there is one. Both saying something different is counted in `disagree`, and the
164// events stand. `sources` counts the sessions given by where their outcome came from.
165export const combineOutcomes = (records: readonly TurnRecord[], facets: ReadonlyMap<string, Verdict>): { outcomes: Map<string, Verdict>; sources: OutcomeSources } => {
166 const by = new Map<string, TurnRecord[]>()
167 for (const r of records) by.set(r.session, [...(by.get(r.session) ?? []), r])
168 const outcomes = new Map<string, Verdict>()
169 const sources: OutcomeSources = { events: 0, facets: 0, both: 0, disagree: 0, unclear: 0 }
170 const joined = eventsBySession(records)
171 for (const session of by.keys()) {
172 const j = joined.get(session)
173 const fromEvents = judge(j?.own ?? [], null, j?.foreign).verdict
174 const fromFacet = facets.get(session) ?? 'unclear'
175 if (fromEvents !== 'unclear') {
176 outcomes.set(session, fromEvents)
177 if (fromFacet === 'unclear') sources.events += 1
178 else {
179 sources.both += 1
180 if (fromFacet !== fromEvents) sources.disagree += 1
181 }
182 } else if (fromFacet !== 'unclear') {
183 outcomes.set(session, fromFacet)
184 sources.facets += 1
185 } else sources.unclear += 1
186 }
187 return { outcomes, sources }
188}
189hooks/probes.ts 51 lines1// Which sessions are probes (a test of the harness, not work): kept out of learning, still counted.
2// Pure. One rule, written once here; the register stamps the origin on every record, the replay of an
3// old ledger reads the transcript path the store kept.
4
5import type { ProbeReason, TurnRecord } from '../types'
6
7// source: macOS and Linux temp roots (`/tmp` -> `/private/tmp`, `$TMPDIR` under /var/folders), the
8// directories `claude -p` probes and the harness's own scratch runs were found in (audit
9// 2026-10-15: 9 of 20 probe sessions under /private/tmp, the 6 others under a scratch path); the
10// caller adds the process's TMPDIR, TEMP and TMP.
11export const TEMP_ROOTS: readonly string[] = ['/tmp', '/private/tmp', '/var/tmp', '/private/var/tmp', '/var/folders', '/private/var/folders']
12
13const norm = (p: string): string => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
14
15export const isUnderTemp = (cwd: string, extraRoots: readonly string[] = []): boolean => {
16 const dir = norm(cwd)
17 return [...TEMP_ROOTS, ...extraRoots].map(norm).filter((r) => r !== '').some((r) => dir === r || dir.startsWith(`${r}/`))
18}
19
20// A transcript path is `<config>/projects/<cwd with / and . as ->/<session>.jsonl`: the folder name
21// is the working directory of the session. Only the temp roots' encodings are read from it.
22export const tempProjectDir = (transcript: string): boolean => {
23 const m = /\/projects\/([^/]+)\/[^/]+$/.exec(transcript.replace(/\\/g, '/'))
24 return m !== null && /^-(?:private-)?(?:tmp|var-tmp|var-folders)(?:-|$)/.test(m[1]!)
25}
26
27export type ProbeInfo = { probe: boolean; reason: ProbeReason | null; steps: number }
28
29// A session is a probe when (1) its working directory is under the OS temp dir (stamped on its
30// records, or read from its transcript path), whatever its length; or (2) it was non-interactive (a
31// `-p` run or the SDK) and took at most one main step; or (3) nothing is known of its origin
32// (records written before the origin was kept) and it is a single main step with no agent. The step
33// count is main's steps, not turns: a 1-turn session of 3 steps is work.
34export const probeOf = (records: readonly TurnRecord[], transcript?: string): ProbeInfo => {
35 const steps = records.reduce((n, r) => n + r.main.reduce((m, run) => m + run.steps, 0), 0)
36 const agents = records.some((r) => r.agents.length > 0)
37 const stamped = records.some((r) => r.nonInteractive !== undefined || r.tmpCwd === true)
38 if (records.some((r) => r.tmpCwd === true) || (transcript !== undefined && tempProjectDir(transcript))) return { probe: true, reason: 'temp-cwd', steps }
39 if (records.some((r) => r.nonInteractive === true) && steps <= 1) return { probe: true, reason: 'non-interactive-1-step', steps }
40 if (!stamped && steps <= 1 && !agents) return { probe: true, reason: 'one-step-unknown', steps }
41 return { probe: false, reason: null, steps }
42}
43
44export const probesOf = (records: readonly TurnRecord[], transcripts: ReadonlyMap<string, string> = new Map()): Map<string, ProbeInfo> => {
45 const by = new Map<string, TurnRecord[]>()
46 for (const r of records) by.set(r.session, [...(by.get(r.session) ?? []), r])
47 return new Map([...by].map(([s, rs]) => [s, probeOf(rs, transcripts.get(s))]))
48}
49
50export const withoutProbes = <T extends { session: string }>(records: readonly T[], probes: ReadonlyMap<string, ProbeInfo>): T[] => records.filter((r) => probes.get(r.session)?.probe !== true)
51hooks/shared.ts 55 lines1import type { EngineInterface } from 'claude-code'
2
3import type { InsightsState, TurnRecord } from '../types'
4import type { Layout } from './ledger'
5import { emptyDelegations } from './delegations'
6import { emptyCalibration } from './learn'
7import { placePath } from './paths'
8
9// The panel's initial state and the small helpers the register and the modules split from it share. The
10// engine's scan reads a state handle only where it is declared in the file that uses it, so each file
11// that reads or writes the panel declares `atom({ plugin, key }, INITIAL)` itself.
12
13export const INITIAL: InsightsState = {
14 mode: 'observe',
15 turns: 0,
16 sessions: 0,
17 judgedSessions: 0,
18 probes: { sessions: 0, turns: 0, byReason: { 'temp-cwd': 0, 'non-interactive-1-step': 0, 'one-step-unknown': 0 } },
19 outcomeSources: { events: 0, facets: 0, both: 0, disagree: 0, unclear: 0 },
20 ledgerError: null,
21 facetsError: null,
22 phases: [],
23 trends: [],
24 models: [],
25 delegations: emptyDelegations(),
26 mechanical: { sessions: 0, heavyReads: 0, heavySteps: 0, unmeasured: 0, share: null },
27 brief: null,
28 frictions: [],
29 cortex: { pushed: 0, pending: 0, lastError: null, lastAt: null },
30 agents: [],
31 advisor: { heavyTurns: 0, heavyBeforeWrite: 0, heavyAtCompletion: 0, lightTurns: 0, lightWithAdvisor: 0 },
32 goals: [],
33 goalsError: null,
34 calibration: emptyCalibration(),
35 calibrationError: null,
36 proposals: [],
37 updatedAt: null,
38}
39
40
41export const recordsOf = (view: Layout): TurnRecord[] => [...view.turns.values()].flat()
42
43export const cut = (error: unknown): string => (error instanceof Error ? error.message : String(error)).slice(0, 120)
44
45export async function expandHome($: EngineInterface, path: string): Promise<string> {
46 if (!path.startsWith('~/')) return path
47 const placed = placePath(
48 { home: await $.env.get('HOME'), userProfile: await $.env.get('USERPROFILE'), configDir: await $.env.get('CLAUDE_CONFIG_DIR') },
49 path,
50 )
51 if ('reason' in placed) throw new Error(placed.reason)
52
53 return placed.path
54}
55hooks/store-io.ts 177 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { TurnRecord } from '../types'
5import { LEDGER_CAP } from './learn'
6import {
7 ENDED,
8 FRICTIONS,
9 LEGACY,
10 LEGACY_PUSHED,
11 LEGACY_TURNS,
12 type Layout,
13 PUSHED,
14 SESSION_PREFIXES,
15 STALE_MS,
16 type Sized,
17 TRANSCRIPT,
18 TURNS,
19 evictions,
20 isCovered,
21 keyBytes,
22 keyOf,
23 layoutOf,
24 migrationPlan,
25 newestAt,
26 orphans,
27 ownWithin,
28 pushedOf,
29 sessionOfKey,
30} from './ledger'
31import { cut, INITIAL } from './shared'
32
33const insights = atom({ plugin: 'zetetic-insights', key: 'insights' } as const, INITIAL)
34
35// The ledger this process holds and writes (ledger.ts invariants I1-I5): the own session's records, a
36// snapshot of every other session's read at start, and the layout they came from. Rebuilt at start,
37// and again before the first write after a hot reload (register() runs again and resets it).
38export const held: { ownSid: string | null; own: TurnRecord[]; others: TurnRecord[]; layout: Layout | null } = { ownSid: null, own: [], others: [], layout: null }
39
40// Every session's records in time order, the newest LEDGER_CAP, as the single-key ledger held them.
41export const ledger = (): TurnRecord[] => [...held.others, ...held.own].sort((a, b) => a.at - b.at).slice(-LEDGER_CAP)
42
43// Set by the handover module: the note of the frictions that could not be measured, kept so a turn's save does not erase it.
44export const note: { frictionError: string | null } = { frictionError: null }
45
46// I5: the legacy single-key ledger and pushed list moved to legacy:<sid> and pushed:<sid>, then deleted.
47async function migrate($: EngineInterface, keys: readonly string[]): Promise<string | null> {
48 if (!keys.includes(LEGACY_TURNS) && !keys.includes(LEGACY_PUSHED)) return null
49 const legacyPushed = await $.store.get(LEGACY_PUSHED)
50 const kept = new Map<string, TurnRecord[]>()
51 for (const id of Array.isArray(legacyPushed) ? legacyPushed : []) {
52 const raw = typeof id === 'string' ? await $.store.get(keyOf(TURNS, id)) : undefined
53 if (Array.isArray(raw)) kept.set(id as string, raw as TurnRecord[])
54 }
55 const plan = migrationPlan(await $.store.get(LEGACY_TURNS), legacyPushed, new Set(keys), (id) => kept.get(id))
56 for (const [key, value] of plan.sets) await $.store.set(key, value)
57 for (const key of plan.deletes) await $.store.delete(key)
58 return plan.malformed ? `the store's ${LEGACY_TURNS} was not a list, it was dropped` : null
59}
60
61export async function readLayout($: EngineInterface, keys: readonly string[]): Promise<Layout> {
62 const entries = new Map<string, unknown>()
63 for (const key of keys) if (SESSION_PREFIXES.some((p) => sessionOfKey(key, p) !== null)) entries.set(key, await $.store.get(key))
64 return layoutOf(entries)
65}
66
67// The ledger as the store keeps it, after the migration: the own session's records, and a snapshot
68// of every other session's. A store that cannot be read is named and leaves the ledger unloaded, so
69// no write replaces the own key from an empty copy. A resumed session is live again: its ended: goes.
70export async function loadLedger($: EngineInterface): Promise<string | null> {
71 held.ownSid = null
72 held.own = []
73 held.others = []
74 held.layout = null
75 try {
76 const sid = await $.session.id()
77 const migrated = await migrate($, await $.store.keys())
78 const view = await readLayout($, await $.store.keys())
79 if (view.ended.has(sid)) await $.store.delete(keyOf(ENDED, sid))
80 for (const prefix of [TRANSCRIPT, FRICTIONS]) if (view.transcripts.has(sid) || view.frictions.has(sid)) await $.store.delete(keyOf(prefix, sid)) // its transcript grows again: named and measured afresh when it ends
81 held.own = [...(view.turns.get(sid) ?? [])]
82 held.others = [...view.turns].filter(([s]) => s !== sid).flatMap(([, records]) => records)
83 held.layout = view
84 held.ownSid = sid
85 const bad = view.malformed.length === 0 ? null : `${view.malformed.length} malformed, first ${view.malformed[0]}`
86 return migrated ?? bad
87 } catch (error) {
88 return cut(error)
89 }
90}
91
92// Before a write: the module holds the ledger of this session, or loads it (after a hot reload, or
93// a session id that changed under it). A load that fails leaves the write undone and named.
94export async function loaded($: EngineInterface, sid: string): Promise<boolean> {
95 if (held.ownSid === sid) return true
96 const error = await loadLedger($)
97 if (held.ownSid === sid) return true
98 await update($, insights, (s) => ({ ...s, ledgerError: `not saved: ${error ?? 'the session id changed while loading'}` }))
99 return false
100}
101
102
103// I4: the own records written under the byte budget, measured over every records key as read now.
104// Ended sessions are evicted when the own write would not fit (ledger.ts evictions), then keys left
105// without their session. A session without ended: is never deleted here (I2); when the own write
106// cannot fit without that, it is refused and named in the panel, never left to fail silently.
107// The one race left (I2): this process reads ended:S, then S's process resumes and deletes it, then
108// this process evicts S, within the few store calls between that read and the deletes (milliseconds).
109// S loses its stored records until its next own write rewrites turns:S from the module copy, which
110// holds them all, except after a hot reload before S's first turn (the module copy is empty then).
111export async function saved($: EngineInterface, sid: string, next: TurnRecord[]): Promise<void> {
112 try {
113 const keys = await $.store.keys()
114 const ended = new Set(keys.map((k) => sessionOfKey(k, ENDED)).filter((x): x is string => x !== null))
115 const bySession = new Map<string, { bytes: number; records: TurnRecord[] }>()
116 let ownLegacy = 0
117 for (const key of keys) {
118 const session = sessionOfKey(key, TURNS) ?? sessionOfKey(key, LEGACY)
119 if (session === null) continue
120 const raw = await $.store.get(key)
121 if (!Array.isArray(raw)) continue
122 if (session === sid) {
123 if (key !== keyOf(TURNS, sid)) ownLegacy += keyBytes(key, raw as TurnRecord[]) // legacy:<own>, written by the migration only
124 continue
125 }
126 const s = bySession.get(session) ?? { bytes: 0, records: [] }
127 bySession.set(session, { bytes: s.bytes + keyBytes(key, raw as TurnRecord[]), records: [...s.records, ...(raw as TurnRecord[])] })
128 }
129 const sized: Sized[] = []
130 for (const [session, s] of bySession) {
131 const newest = newestAt(s.records)
132 const covered = isCovered(pushedOf(await $.store.get(keyOf(PUSHED, session))), newest)
133 sized.push({ session, bytes: s.bytes, newest, ended: ended.has(session), pushed: covered })
134 }
135 const mine = ownWithin(sid, next)
136 const evict = evictions(sized, keyBytes(keyOf(TURNS, sid), mine) + ownLegacy)
137 if (evict === null) throw new Error('the live sessions leave no room for this session within the ledger budget')
138 for (const session of evict) for (const prefix of SESSION_PREFIXES) await $.store.delete(keyOf(prefix, session))
139 for (const key of orphans(keys, sid)) await $.store.delete(key)
140 if (evict.length > 0) held.others = held.others.filter((r) => !evict.includes(r.session))
141 held.own = mine
142 await $.store.set(keyOf(TURNS, sid), held.own)
143 await update($, insights, (s) => ({ ...s, ledgerError: note.frictionError }))
144 } catch (error) {
145 await update($, insights, (s) => ({ ...s, ledgerError: `not saved: ${cut(error)}` }))
146 }
147}
148
149// session.end, also fired with the old id by /clear: the session is finished and other processes may
150// hand it over. One local set of ended:<sid> = { at }, well inside the hook's 1.5 s. Only a session
151// with records is marked, so no ended: key outlives a session without a turns: key.
152// classic.SessionEnd writes ONLY transcript:<sid> = { path } (the source of the session's frictions),
153// by the same guard. The two hooks share no key, so nothing depends on which runs first, or on one
154// not running: the measurement reads both keys. Classic hooks do not run under organisation-managed
155// settings: then no transcript: key exists and the session's frictions are simply not measured.
156async function hasRecords($: EngineInterface, sid: string): Promise<boolean> {
157 if (held.ownSid === sid) return held.own.length > 0
158 return (await $.store.get(keyOf(TURNS, sid))) !== undefined || (await $.store.get(keyOf(LEGACY, sid))) !== undefined
159}
160
161export async function markEnded($: EngineInterface, sid: string): Promise<void> {
162 try {
163 if (await hasRecords($, sid)) await $.store.set(keyOf(ENDED, sid), { at: await $.clock.now() })
164 } catch (error) {
165 await update($, insights, (s) => ({ ...s, ledgerError: `not marked ended (finished after ${STALE_MS / 3_600_000} h idle instead): ${cut(error)}` }))
166 }
167}
168
169export async function markTranscript($: EngineInterface, sid: string, path: string): Promise<void> {
170 try {
171 if (path !== '' && (await hasRecords($, sid))) await $.store.set(keyOf(TRANSCRIPT, sid), { path })
172 } catch (error) {
173 await update($, insights, (s) => ({ ...s, ledgerError: `transcript not named (its frictions are not measured): ${cut(error)}` }))
174 }
175}
176
177