SLOPSHOPPER

zetetic-insights

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…

newguardcommandprompttimeragents
v0.8.1no licenseupdated 2026-10-15cdeust/claude-mods/mods/zetetic-insights
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · zetetic-insights
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /calibrate ⎿ zetetic-insights: Ledger: 0 turns over 0 sessions, 0 judged by /insights. Calibration: none (medium everywhere). Proposals: none ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

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:

ModOwnsDepends on
cortex-guardrefusals: wiki pages only through wiki_write, worktrees inside <repo>/.claude/worktrees/nothing
cortex-wiki/wiki <wiki/**.md> to PDFnothing
zetetic-geniusstate: the request grade (task class → effort), the genius patterns and skills it matchesnothing
zetetic-autopilotcontext, 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 callszetetic-genius
zetetic-insightsinsights: 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 readszetetic-autopilot, zetetic-genius
cortex-cockpitstats, ledger, tally, stages, hygiene; the /cortex pane draws the rest (PHASES, INSIGHTS, POLICY...)the four above
mission-controlrenderer; 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 elsewherezetetic-autopilot, zetetic-insights
harness-fleetfleet: 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 modelguard, genius, autopilot
superpowersthird-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 measurementnothing

Developing

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

Installing

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

Without write access to settings.json

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:

  • The 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.
  • Options cannot be set (they live in settings.json under pluginConfigs), so every default in a plugin.json has to be right on any machine:
ModOptionDefaultNotes
cortex-guardhygiene_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-geniusmode, classifier_modelobserve, haikuthe grade is one low-effort completion on the haiku alias
zetetic-autopilotpolicy_mode, quota_pressure_percent, offload_threshold_chars, offload_preview_charsenforce, 80, 30000, 2000a 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-cockpitsurface, cortex_serverink, plugin_hypermnesia-mcp_cortexthe server name as /mcp lists it
mission-controlrendererautoauto draws pixels in kitty or Ghostty outside tmux (TMUX, TERM, TERM_PROGRAM, KITTY_WINDOW_ID), cells elsewhere; cells or pixels forces one
harness-fleetgithub_owner, refresh_on_startcdeust, truereads gh pr list, gh issue list and git remote
  • The machine must let the mods do what they ask: 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.

Source 20 files
hooks/register.tsx 395 lines
1import { 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}
395
hooks/brief.ts 127 lines
1// 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))
127
hooks/delegations.ts 146 lines
1// 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}
146
hooks/handover.ts 233 lines
1import { 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}
233
hooks/learn.ts 221 lines
1// 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}
221
hooks/ledger.ts 225 lines
1// 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
225
hooks/models.ts 59 lines
1// 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}
59
hooks/observe.ts 59 lines
1import 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
59
hooks/outcome.ts 189 lines
1// 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}
189
hooks/probes.ts 51 lines
1// 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)
51
hooks/shared.ts 55 lines
1import 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}
55
hooks/store-io.ts 177 lines
1import { 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