SLOPSHOPPER

clm

Replaces compaction for the main conversation: older turns are folded into a ledger (goal, user instructions, done, to do, open questions, key facts) and the…

newrowsguardcommandpromptmodel
★ 1v0.3.0no licenseupdated 2026-10-08RanolP/.dotfiles/nix/home/configs/claude/mods/clm
A shopper browsing a rack in a slop shop
README

.dotfiles

Installation

Windows CMD

Requirements:

  • Windows CMD
  • curl executable
  • Winget executable (>= v1.6.2631, follow instruction from winget repository if you don't have or have a lower version)
curl -L dotfiles.ranolp.dev/setup | cmd /Q

Windows PowerShell (TODO)

Requirements:

  • Windows PowerShell
  • Winget executable (>= v1.6.2631, follow instruction from winget repository if you don't have or have a lower version)
curl dotfiles.ranolp.dev/setup | iex

Windows ArchWSL (WIP)

Requirements:

  • ArchWSL Bash
  • curl executable
curl -L dotfiles.ranolp.dev/setup | sh

macOS (TODO)

Requirements:

  • macOS zsh
  • curl executable
curl -L dotfiles.ranolp.dev/setup | sh
Source 7 files
hooks/register.ts 1203 lines
1import type { BuiltinToolInputs, EngineInterface, Register, SessionMessage } from 'claude-code'
2
3import { boardHtml } from './board'
4import {
5  buildCleared, fingerprint, isLedgerRow, isSystemRow, isUserTyped, stripReminders, ledgerRowSeq, ledgerRowText, liveRows, planClear, protectedIndex, sum, visibleTokens,
6  type Boundary, type ClearPlan,
7} from './fold'
8import {
9  checkEvidence, describeOp, downgrade, gateOps, isDoneClaim, renderRange,
10  type Evidence, type Source,
11} from './evidence'
12import {
13  composeLedger, EMPTY_LEDGER, factLines, fallbackLedger, instructionKey, instructionValues, issueList, legacyItems, MERGE_SYSTEM, mergeableNotes, normalizeLedger, notesOf, oversizeLines,
14  parseChanges, parseMerge, parseReview, preservedLines, preserveInstructions, rememberFacts, rememberOversize, rememberPreserved, renderTurns, REVIEW_SYSTEM, withheldLines, withheldPointer,
15  withNotes, WITHDRAWN_PREFIX, type Fact,
16} from './ledger'
17import {
18  archivedDone, describe, injected, normalizeRemote, parseLog, parseOps, snapshot, toEvents,
19  type Issue, type Origin, type Payload, type TrackerEvent,
20} from './tracker'
21
22// clm replaces the engine's compaction for the main conversation. When the
23// rows outgrow a token budget, a cheap model folds the turns about to go into a
24// ledger (goal, user instructions, work done, work left, open questions, key
25// facts), and this plugin's `session.compact` hook rewrites the transcript to
26// [first request, ledger row, newest turns]. The engine summarizer never runs:
27// every main-session compaction, /compact and the engine's own threshold
28// included, is answered here, and the ahead-of-time `precompute` is refused.
29//
30// 한 일, 할 일 and 미결 질문 are not merged text: they are rendered from the
31// tracker (tracker.ts), and the merge model only emits ops against it, so an
32// item it forgets to repeat stays where it was.
33//
34// Interactive sessions fold right after the turn that crossed the budget. A
35// headless (-p / SDK) session refuses a plugin-raised compaction, so there the
36// plan waits in the store for the next /compact or engine compaction.
37
38const FAILS_BEFORE_FALLBACK = 3
39const HYSTERESIS_FLOOR = 0.9
40
41// --- options -------------------------------------------------------------
42
43export type Opts = { budget: number; tailTarget: number; reserve: number; model: string; reviewModel: string }
44/** Reads userConfig, naming every value it could not use and the value used instead. */
45export function readOpts(o: Record<string, unknown>): { opts: Opts; problems: string[] } {
46  const problems: string[] = []
47  const isInt = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v)
48  let budget = 32000
49  if (o.budget !== undefined) {
50    if (isInt(o.budget) && o.budget >= 2000) budget = o.budget
51    else {
52      budget = typeof o.budget === 'number' && Number.isFinite(o.budget) && o.budget > 0 ? Math.max(2000, Math.round(o.budget)) : 32000
53      problems.push(`budget=${JSON.stringify(o.budget)} must be an integer of at least 2000; using ${budget}`)
54    }
55  }
56  const half = Math.floor(budget / 2)
57  let tailTarget = half
58  if (o.tailTarget !== undefined && o.tailTarget !== 0) {
59    if (isInt(o.tailTarget) && o.tailTarget > 0 && o.tailTarget < budget) tailTarget = o.tailTarget
60    else problems.push(`tailTarget=${JSON.stringify(o.tailTarget)} must be a positive integer under the budget (${budget}); using ${half}`)
61  }
62  const defReserve = Math.min(2048, Math.floor(budget / 4))
63  let reserve = defReserve
64  if (o.reserve !== undefined && o.reserve !== 0) {
65    if (isInt(o.reserve) && o.reserve > 0 && o.reserve < budget) reserve = o.reserve
66    else problems.push(`reserve=${JSON.stringify(o.reserve)} must be a positive integer under the budget (${budget}); using ${defReserve}`)
67  }
68  const name = (key: 'model' | 'reviewModel', fallback: string) => {
69    const v = o[key]
70    if (v === undefined || v === '') return fallback
71    if (typeof v === 'string' && v.trim()) return v.trim()
72    problems.push(`${key}=${JSON.stringify(v)} must be a model name; using ${fallback}`)
73    return fallback
74  }
75  const model = name('model', 'haiku')
76  return { opts: { budget, tailTarget, reserve, model, reviewModel: name('reviewModel', model) }, problems }
77}
78
79// --- session state -------------------------------------------------------
80
81// A fold's `ops` wait here and reach the tracker only in session.compact, when
82// the fold is applied, so a planned fold that never lands (a headless session,
83// a re-fold) cannot leave issues behind for the next one to duplicate. The
84// tracker writes at call time are mirrorModelTask, for the model's own task
85// calls, and stepOnce, for the steps each tool call shows.
86type Pending = {
87  notes: string; fullNotes: string; ops: Payload[]; seq: number; keepFp?: string; keepFrom: number; dropFps: string[]
88  cuts: Record<string, number>; keptTurns: number; fallback: boolean; foldMs?: number
89  promoted?: Payload[]; hold?: Held[]; settled?: string[]
90}
91// `foldTokens` is the usage reading taken when the last fold landed: the
92// engine keeps reporting it until the next API response, so an equal reading
93// describes the transcript before that fold and is ignored.
94export type Meta = { seq: number; lastClear?: string; lastFoldAt?: number; foldDurationsMs?: Record<string, number>; foldTokens?: number; boundary?: Boundary; lastSkip?: string; fails: number; ratio?: number; overhead?: number; lastObserved?: number }
95const sid = ($: EngineInterface) => $.session.id()
96const metaKey = async ($: EngineInterface) => `ledger:${await sid($)}`
97const pendingKey = async ($: EngineInterface) => `pending:${await sid($)}`
98const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
99const isNumberRecord = (v: unknown): v is Record<string, number> => isRecord(v) && Object.values(v).every(n => typeof n === 'number')
100const optional = (v: unknown, type: 'number' | 'string') => v === undefined || typeof v === type
101const isMeta = (v: unknown): v is Partial<Meta> =>
102  isRecord(v) && optional(v.seq, 'number') && optional(v.fails, 'number') && optional(v.ratio, 'number') && optional(v.overhead, 'number')
103  && optional(v.foldTokens, 'number') && optional(v.lastFoldAt, 'number') && (v.foldDurationsMs === undefined || isNumberRecord(v.foldDurationsMs)) && optional(v.lastClear, 'string') && (v.boundary === undefined || isRecord(v.boundary))
104const isPending = (v: unknown): v is Pending =>
105  isRecord(v) && typeof v.notes === 'string' && typeof v.fullNotes === 'string' && Array.isArray(v.ops) && typeof v.seq === 'number'
106  && optional(v.keepFp, 'string') && typeof v.keepFrom === 'number' && Array.isArray(v.dropFps) && v.dropFps.every(f => typeof f === 'string')
107  && isRecord(v.cuts) && typeof v.keptTurns === 'number' && typeof v.fallback === 'boolean' && optional(v.foldMs, 'number')
108  && (v.promoted === undefined || Array.isArray(v.promoted)) && (v.hold === undefined || (Array.isArray(v.hold) && v.hold.every(isHeld)))
109  && (v.settled === undefined || (Array.isArray(v.settled) && v.settled.every(k => typeof k === 'string')))
110async function readMeta($: EngineInterface): Promise<Meta> {
111  const stored = await $.store.get(await metaKey($))
112  return { seq: 0, fails: 0, ...(isMeta(stored) ? stored : {}) }
113}
114const writeMeta = async ($: EngineInterface, m: Meta) => $.store.set(await metaKey($), m)
115
116async function basePath($: EngineInterface): Promise<string> {
117  const home = await $.env.get('HOME')
118  return `${home ?? '.'}/.claude-work/plans/clm-${await sid($)}`
119}
120const ledgerPath = async ($: EngineInterface) => `${await basePath($)}.md`
121const logPath = async ($: EngineInterface) => `${await basePath($)}.log.jsonl`
122async function readText($: EngineInterface, p: string): Promise<string | undefined> {
123  return (await $.fs.exists(p)) ? await $.fs.read(p) : undefined
124}
125
126export type LogEvent = 'clear' | 'skip-not-shrinking' | 'merge-invalid' | 'merge-timeout' | 'ops-rejected' | 'fallback' | 'truncation' | 'escape' | 'panel-relink'
127  | 'evidence-rejected' | 'review-rejected' | 'review-failed' | 'review-dropped' | 'escape-instructions-lost'
128type LogFields = { tokensBefore?: number; tokensAfter?: number; keptTurns?: number; ratio?: number; reason?: string }
129const LOG_KEEP = 200
130async function logEvent($: EngineInterface, event: LogEvent, f: LogFields) {
131  try {
132    const p = await logPath($)
133    const lines = ((await readText($, p)) ?? '').split('\n').filter(Boolean)
134    lines.push(JSON.stringify({ ts: new Date().toISOString(), event, ...f }))
135    await $.fs.write(p, `${lines.slice(-LOG_KEEP).join('\n')}\n`)
136  } catch { /* the log must never stop a fold */ }
137}
138
139const RATIO_MIN = 1, RATIO_MAX = 4
140const shownChanges = new Map<string, Set<string>>()
141const changeHash = (line: string): string => {
142  let h = 2166136261
143  for (let i = 0; i < line.length; i++) h = Math.imul(h ^ line.charCodeAt(i), 16777619)
144  return (h >>> 0).toString(16)
145}
146// clm-prompt
147const TURN_CHANGE_SYSTEM = 'Summarize only concrete non-task context changes from the finished coding turn. Reply with <changes>, at most three short Korean lines, or <changes></changes> when nothing changed.'
148/** The engine's live context tokens, when it has a reading. */
149async function usageReading($: EngineInterface): Promise<number | undefined> {
150  try {
151    const { tokens } = (await $.session.usage()).context
152    return typeof tokens === 'number' && Number.isFinite(tokens) ? tokens : undefined
153  } catch {
154    return undefined
155  }
156}
157const freshReal = (reading: number | undefined, meta: Meta) => (reading !== meta.foldTokens ? reading : undefined)
158/**
159 * The message tokens the budget counts: the live reading less `meta.overhead`
160 * (system prompt and tool schemas), never below the rows' own estimate. Until
161 * a reading has set that baseline the reading cannot be split, so the
162 * estimate stands; counting the whole reading put ~35k of system prompt
163 * against a 32k budget and folded every few calls.
164 */
165export const messageTokens = (rows: readonly SessionMessage[], observed: number | undefined, meta: Pick<Meta, 'overhead'>) => {
166  const estimated = sum(rows)
167  return observed === undefined || meta.overhead === undefined ? estimated : Math.max(estimated, observed - meta.overhead)
168}
169const clampRatio = (n: number) => Math.round(Math.min(RATIO_MAX, Math.max(RATIO_MIN, n)) * 1000) / 1000
170
171async function skip($: EngineInterface, why: string) {
172  $.ui.log(`clm: fold skipped, ${why}`, { to: 'debug' })
173  await writeMeta($, { ...(await readMeta($)), lastSkip: why })
174}
175
176// --- tracker I/O -----------------------------------------------------------
177
178async function trackerDir($: EngineInterface): Promise<string> {
179  return `${(await $.env.get('HOME')) ?? '.'}/.claude-work/tracker/events`
180}
181const logFile = async ($: EngineInterface, session: string) => `${await trackerDir($)}/${session}.jsonl`
182
183async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | undefined> {
184  try {
185    const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 5000 })
186    return r.exitCode === 0 && r.stdout.trim() ? r.stdout.trim() : undefined
187  } catch {
188    return undefined
189  }
190}
191
192/** repo is the origin remote, else the worktree's top level, else the cwd. */
193async function captureOrigin($: EngineInterface): Promise<Origin> {
194  const session = await $.session.id()
195  const cwd = await $.session.cwd()
196  const remote = await git($, cwd, ['remote', 'get-url', 'origin'])
197  const repo = remote ? normalizeRemote(remote) : ((await git($, cwd, ['rev-parse', '--show-toplevel'])) ?? cwd)
198  const branch = await git($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
199  return { session, repo, ...(branch ? { branch } : {}), cwd }
200}
201
202type CachedLog = { size: number; mtimeMs: number; events: TrackerEvent[] }
203const trackerCache = new Map<string, CachedLog>()
204async function readAll($: EngineInterface): Promise<TrackerEvent[]> {
205  const dir = await trackerDir($)
206  if (!(await $.fs.exists(dir))) return []
207  const out: TrackerEvent[] = []
208  for (const f of await $.fs.list(dir)) {
209    if (f.kind !== 'file' || !f.name.endsWith('.jsonl')) continue
210    try {
211      const cached = trackerCache.get(f.name)
212      const events = cached && cached.size === f.size && cached.mtimeMs === f.mtimeMs
213        ? cached.events
214        : parseLog(await $.fs.read(dir + '/' + f.name))
215      trackerCache.set(f.name, { size: f.size, mtimeMs: f.mtimeMs, events })
216      out.push(...events)
217    } catch (err) {
218      $.ui.log(`clm tracker: could not read ${f.name} (${String(err).slice(0, 120)})`, { to: 'debug' })
219    }
220  }
221  return out
222}
223
224// Each session's log is a read-then-rewrite; parallel tool calls (two
225// TaskCreate in one response) would otherwise both read the same lastSeq and
226// the second write would drop the first's events. Every write of one session
227// runs on this chain, one after another.
228const writeChains = new Map<string, Promise<unknown>>()
229function serialized<T>(session: string, run: () => Promise<T>): Promise<T> {
230  const result = (writeChains.get(session) ?? Promise.resolve()).then(run, run)
231  writeChains.set(session, result.then(() => undefined, () => undefined))
232  return result
233}
234
235/** Writes events to this session's log; only this session ever writes that file. Callers hold `serialized`. */
236async function writeEvents($: EngineInterface, payloads: readonly Payload[], o: Origin): Promise<TrackerEvent[]> {
237  const p = await logFile($, o.session)
238  const prev = (await $.fs.exists(p)) ? await $.fs.read(p) : ''
239  const lastSeq = parseLog(prev).reduce((n, e) => Math.max(n, e.seq), 0)
240  const events = toEvents(payloads, o, lastSeq, new Date().toISOString())
241  const body = prev && !prev.endsWith('\n') ? `${prev}\n` : prev
242  await $.fs.write(p, `${body}${events.map(e => JSON.stringify(e)).join('\n')}\n`)
243  return events
244}
245
246// `project` is false where the caller projects later, once its own writes are
247// done. `knownTitles` drops a model-derived create whose title an open or done
248// issue of this session already carries: per-tool-call steps and the fold read
249// overlapping turns, and whichever lands second must not add the step again.
250async function append($: EngineInterface, payloads: readonly Payload[], origin?: Origin, project = true, knownTitles = false): Promise<TrackerEvent[]> {
251  if (payloads.length === 0) return []
252  const o = origin ?? (await captureOrigin($))
253  return serialized(o.session, async () => {
254    const before = snapshot(await readAll($))
255    const titles = new Set(knownTitles ? before.filter(i => i.origin.session === o.session && i.status !== 'dropped').map(i => i.title.trim()) : [])
256    const fresh = payloads.filter(p => (p.op !== 'status' || before.find(i => i.id === p.issue)?.status !== p.status)
257      && !(p.op === 'create' && titles.has(p.title.trim())))
258    if (fresh.length === 0) return []
259    const events = await writeEvents($, fresh, o)
260    try {
261      for (const l of describe(events, before)) $.ui.log(l)
262    } catch { /* display notices never block tracker writes */ }
263    if (project) await syncPanelSafely($, o, events)
264    return events
265  })
266}
267
268export async function sessionIssues($: EngineInterface, query: { issue?: string; status?: Issue['status'] } = {}): Promise<Issue[]> {
269  const session = (await captureOrigin($)).session
270  return snapshot(await readAll($)).filter(i => i.origin.session === session
271    && (query.issue === undefined || i.id === query.issue)
272    && (query.status === undefined || i.status === query.status))
273}
274
275export async function trackIssue($: EngineInterface, input: { title: string; status: Issue['status']; issue?: string }): Promise<Issue> {
276  const origin = await captureOrigin($)
277  if (input.issue !== undefined) {
278    const before = (await sessionIssues($, { issue: input.issue }))[0]
279    if (!before) throw new Error(`clm issue ${input.issue} does not belong to this session`)
280    if (before.status !== input.status) await append($, [{ op: 'status', issue: input.issue, status: input.status }], origin)
281    const updated = (await sessionIssues($, { issue: input.issue }))[0]
282    if (!updated) throw new Error('clm tracker did not return the tracked issue')
283    return updated
284  }
285  const title = input.title.trim()
286  if (!title) throw new Error('clm issue title must not be empty')
287  const events = await append($, [{ op: 'create', title, status: input.status }], origin)
288  const issueId = events[0]?.issue
289  const issue = issueId ? (await sessionIssues($, { issue: issueId }))[0] : undefined
290  if (!issue) throw new Error('clm tracker did not return the tracked issue')
291  return issue
292}
293
294const PANEL_STATUS: Record<Issue['status'], 'pending' | 'in_progress' | 'completed' | 'deleted'> = {
295  todo: 'pending', doing: 'in_progress', done: 'completed', dropped: 'deleted', question: 'pending',
296}
297const panelSubject = (i: Issue) => `${i.status === 'question' ? '질문: ' : ''}${i.title}${i.progress ? ` (${i.progress.done}/${i.progress.total})` : ''}`
298const panelDescription = (i: Issue) => i.notes.at(-1) ?? i.title
299const TASK_STATUS: Record<string, Issue['status'] | undefined> = {
300  pending: 'todo', in_progress: 'doing', completed: 'done', deleted: 'dropped',
301}
302
303type ModelTaskCall =
304  | ({ tool: 'TaskCreate' } & BuiltinToolInputs['TaskCreate'])
305  | ({ tool: 'TaskUpdate' } & BuiltinToolInputs['TaskUpdate'])
306// What a model task call became in the tracker: the issues it wrote, with
307// their state afterwards, and the fields the tracker has no place for; or the
308// panel task id no issue of this session carries.
309type Mirrored = { recorded: Issue[]; unrecorded: string[] } | { unknownTask: string }
310
311/** Turns the model's TaskCreate / TaskUpdate into tracker ops, appended now so the notice shows at call time. */
312async function mirrorModelTask($: EngineInterface, e: ModelTaskCall): Promise<Mirrored> {
313  const origin = await captureOrigin($)
314  const issues = snapshot(await readAll($))
315  const byTask = (taskId: string) => issues.find(i => i.origin.session === origin.session && i.taskId === taskId)
316  const recordedAs = async (id: string | undefined, unrecorded: string[]): Promise<Mirrored> => {
317    const after = id === undefined ? undefined : snapshot(await readAll($)).find(i => i.id === id)
318    return { recorded: after ? [after] : [], unrecorded }
319  }
320  if (e.tool === 'TaskCreate') {
321    const title = e.subject.trim(), note = e.description.trim()
322    if (!title) return { recorded: [], unrecorded: [] }
323    const events = await append($, [{ op: 'create', title, status: 'todo', ...(note ? { note } : {}) }], origin)
324    return recordedAs(events[0]?.issue, e.metadata ? ['metadata'] : [])
325  }
326  const issue = byTask(e.taskId)
327  if (!issue) return { unknownTask: e.taskId }
328  const status = e.status ? TASK_STATUS[e.status] : undefined
329  const named = (ids: readonly string[] | undefined) => (ids ?? []).map(t => byTask(t)?.id ?? `task ${t}`)
330  const blockedBy = named(e.addBlockedBy), blocks = named(e.addBlocks)
331  const subject = e.subject?.trim(), description = e.description?.trim()
332  const ops: Payload[] = [
333    ...(subject && subject !== issue.title ? [{ op: 'retitle' as const, issue: issue.id, title: subject }] : []),
334    ...(description ? [{ op: 'note' as const, issue: issue.id, text: description }] : []),
335    ...(blockedBy.length ? [{ op: 'note' as const, issue: issue.id, text: `blocked by ${blockedBy.join(', ')}` }] : []),
336    ...(blocks.length ? [{ op: 'note' as const, issue: issue.id, text: `blocks ${blocks.join(', ')}` }] : []),
337    ...(status ? [{ op: 'status' as const, issue: issue.id, status }] : []),
338  ]
339  await append($, ops, origin)
340  return recordedAs(issue.id, [...(e.owner ? ['owner'] : []), ...(e.metadata ? ['metadata'] : [])])
341}
342
343// clm-prompt
344function mirrorDeny(m: Mirrored): string {
345  if ('unknownTask' in m)
346    return `clm 트래커에 작업 ${m.unknownTask}이(가) 없다. 이 작업 변경은 답변 본문에 적어라; clm이 다음 정리 때 트래커에 옮긴다.`
347  const lines = m.recorded.map(i => `clm이 트래커에 기록했다: ${i.id} '${i.title}' → ${i.status}`)
348  if (m.unrecorded.length) lines.push(`${m.unrecorded.join(', ')}은(는) 트래커에 칸이 없다. 필요하면 답변 본문에 적어라.`)
349  return lines.length ? lines.join('\n') : '이 작업 변경은 답변 본문에 적어라; clm이 다음 정리 때 트래커에 옮긴다.'
350}
351
352// The model's task calls are recorded in the tracker (and through it the
353// panel) at call time; the deny tells the model what was recorded.
354async function denyModelTask($: EngineInterface, e: ModelTaskCall): Promise<{ deny: string }> {
355  try {
356    return { deny: mirrorDeny(await mirrorModelTask($, e)) }
357  } catch (err) {
358    $.ui.log(`clm tracker: ${e.tool} mirror failed (${String(err).slice(0, 160)})`, { to: 'debug' })
359    // clm-prompt
360    return { deny: 'clm이 이 작업 변경을 기록하지 못했다. 변경 내용을 답변 본문에 적어라; clm이 다음 정리 때 트래커에 옮긴다.' }
361  }
362}
363
364async function createTask($: EngineInterface, issue: Issue): Promise<string> {
365  const r = await $.tool.call({ tool: 'TaskCreate', subject: panelSubject(issue), description: panelDescription(issue) })
366  if ('deny' in r || r.isError) throw new Error('TaskCreate was refused')
367  return r.result.task.id
368}
369
370async function updateTask($: EngineInterface, taskId: string, issue: Issue, status = PANEL_STATUS[issue.status]): Promise<void> {
371  const r = await $.tool.call({
372    tool: 'TaskUpdate', taskId, subject: panelSubject(issue), description: panelDescription(issue), status,
373  })
374  if ('deny' in r || r.isError) throw new Error('TaskUpdate was refused')
375  // A missing task can come back as success: false with no isError.
376  if (!r.result.success) throw new Error(`TaskUpdate failed: ${r.result.error ?? 'success: false'}`)
377}
378
379// One pass over one snapshot: an issue without a task gets one (a dropped or
380// archived one never needs it), a projected issue is updated only when it was
381// touched by `events` or moved into or out of the archive by them, and the new
382// task ids land in one write that does not project again. An archived done
383// issue leaves the panel through the same deleted status a dropped one uses.
384async function syncPanel($: EngineInterface, origin: Origin, events: readonly TrackerEvent[]): Promise<void> {
385  const all = await readAll($)
386  const fresh = new Set(events.map(e => e.seq))
387  const issues = snapshot(all).filter(i => i.origin.session === origin.session)
388  const archived = archivedDone(issues, origin.session)
389  const archivedBefore = archivedDone(snapshot(all.filter(e => e.origin.session !== origin.session || !fresh.has(e.seq))), origin.session)
390  const touched = new Set([
391    ...events.map(e => e.issue),
392    ...[...archived].filter(id => !archivedBefore.has(id)),
393    ...[...archivedBefore].filter(id => !archived.has(id)),
394  ])
395  const panelStatus = (i: Issue) => (archived.has(i.id) ? 'deleted' : PANEL_STATUS[i.status])
396  const links: Payload[] = []
397  for (const issue of issues) {
398    if (issue.taskId ? !touched.has(issue.id) || (archived.has(issue.id) && archivedBefore.has(issue.id)) : panelStatus(issue) === 'deleted') continue
399    try {
400      let taskId = issue.taskId
401      if (!taskId) {
402        taskId = await createTask($, issue)
403        links.push({ op: 'task', issue: issue.id, taskId })
404      } else {
405        try {
406          await updateTask($, taskId, issue, panelStatus(issue))
407        } catch (err) {
408          const oldTaskId = taskId
409          taskId = await createTask($, issue)
410          links.push({ op: 'task', issue: issue.id, taskId })
411          await logEvent($, 'panel-relink', { reason: issue.id + ': ' + oldTaskId + ' -> ' + taskId + ' (' + String(err).slice(0, 120) + ')' })
412          if (panelStatus(issue) !== 'pending') await updateTask($, taskId, issue, panelStatus(issue))
413        }
414      }
415      if (!issue.taskId && panelStatus(issue) !== 'pending') await updateTask($, taskId, issue, panelStatus(issue))
416    } catch (err) {
417      $.ui.log(`clm task panel: ${issue.id} not projected (${String(err).slice(0, 160)})`, { to: 'debug' })
418    }
419  }
420  if (links.length) await writeEvents($, links, origin)
421}
422
423async function syncPanelSafely($: EngineInterface, origin: Origin, events: readonly TrackerEvent[]): Promise<void> {
424  try {
425    if (origin.session === await sid($)) await syncPanel($, origin, events)
426  } catch (err) {
427    $.ui.log(`clm task panel: projection failed (${String(err).slice(0, 160)})`, { to: 'debug' })
428  }
429}
430const projectPanel = ($: EngineInterface, origin: Origin, events: readonly TrackerEvent[]) =>
431  serialized(origin.session, () => syncPanelSafely($, origin, events))
432
433// `/clm board` reads every session's log, writes a self-contained page and opens it.
434async function openBoard($: EngineInterface) {
435  const [events, here] = await Promise.all([readAll($), captureOrigin($)])
436  const issues = snapshot(events).map(i => ({
437    id: i.id, title: i.title, status: i.status, repo: i.origin.repo, ...(i.origin.branch ? { branch: i.origin.branch } : {}), updated: i.updated,
438  }))
439  const path = `${await basePath($)}.board.html`
440  await $.fs.write(path, boardHtml(issues, here.repo, new Date().toISOString()))
441  const r = await $.process.run(['open', path], { timeoutMs: 5000 })
442  if (r.exitCode !== 0) $.ui.log(`clm: could not open the board at ${path} (exit ${r.exitCode}: ${r.stderr.slice(0, 160)})`)
443  else $.ui.log(`clm: board opened (${path})`)
444}
445
446// --- folding -------------------------------------------------------------
447
448// `finished` is this session's archived done issues, shown to the merge model
449// only, so it does not re-create them from the turns being folded.
450type TrackerView = { issues: Issue[]; finished: Issue[]; doneTitles: Set<string> }
451type Fold = { plan: ClearPlan; notes: string; fullNotes: string; ops: Payload[]; seq: number; fallback: boolean; promoted: Payload[]; hold: Held[]; settled: string[] }
452
453// The issues one session's ledger shows, with `ops` previewed on top when the
454// fold has not appended them yet; ids then match what append assigns.
455async function trackerView($: EngineInterface, here: Origin, ops: readonly Payload[] = []): Promise<TrackerView> {
456  const events = await readAll($)
457  const lastSeq = events.reduce((n, e) => (e.origin.session === here.session ? Math.max(n, e.seq) : n), 0)
458  const all = snapshot([...events, ...toEvents(ops, here, lastSeq, new Date().toISOString())])
459  const archived = archivedDone(all, here.session)
460  const done = all.filter(i => i.status === 'done' && i.origin.session === here.session)
461  return {
462    issues: injected(all, here),
463    finished: done.filter(i => archived.has(i.id)), doneTitles: new Set(done.map(i => i.title.trim())),
464  }
465}
466
467// --- evidence gate and review ------------------------------------------------
468
469type Gated = { ops: Payload[]; evidence: Evidence[][]; facts: Fact[] }
470
471/** The mechanical check: a done claim without a held quote is downgraded, a fact without one dropped, and each rejection logged. */
472async function gateMerge($: EngineInterface, ops: readonly Payload[], evidence: readonly Evidence[][], facts: readonly Fact[], sources: ReadonlyMap<string, Source>, ratio: number): Promise<Gated> {
473  const gated = gateOps(ops, evidence, sources)
474  const rejections = [...gated.rejections]
475  const kept: Fact[] = []
476  for (const f of facts) {
477    const { held, problems } = checkEvidence(f.evidence, sources)
478    if (held.length) kept.push({ text: f.text, evidence: held })
479    else rejections.push(`fact "${f.text}" dropped (${problems.join('; ') || 'no evidence'})`)
480  }
481  if (rejections.length) await logEvent($, 'evidence-rejected', { ratio, reason: rejections.join(' | ').slice(0, 2000) })
482  return { ops: gated.ops, evidence: gated.evidence, facts: kept }
483}
484
485// A claim no reviewer has accepted yet is held here, out of the ledger, until
486// a fold's reviewer judges it: a done the per-tool-call step saw (the issue
487// stays doing meanwhile; the step path has the mechanical check only), and a
488// fact whose fold review failed. Each entry keeps the cited rows' text, since
489// those rows may be gone by the time a fold runs, and a nonce: a fold settles
490// (key, nonce), so an entry a step rewrote after the fold read it survives.
491type Held = { key: string; nonce: string; kind: 'step' | 'fact'; issue?: string; text?: string; evidence: Evidence[]; sources: Record<string, Source> }
492const heldKey = async ($: EngineInterface) => `held:${await sid($)}`
493function isHeld(v: unknown): v is Held {
494  return isRecord(v) && typeof v.key === 'string' && typeof v.nonce === 'string' && (v.kind === 'step' || v.kind === 'fact')
495    && optional(v.issue, 'string') && optional(v.text, 'string') && Array.isArray(v.evidence) && isRecord(v.sources)
496}
497const token = (h: Pick<Held, 'key' | 'nonce'>) => `${h.key}#${h.nonce}`
498const newNonce = async ($: EngineInterface) => `${await $.clock.now()}-${Math.random().toString(36).slice(2, 10)}`
499async function readHeld($: EngineInterface): Promise<Held[]> {
500  const v = await $.store.get(await heldKey($))
501  return Array.isArray(v) ? v.filter(isHeld) : []
502}
503// A held fact's stored evidence always holds, so only a verdict settles it;
504// with the reviewer down every fold would add its facts for good and resend
505// them all. Past this many the oldest are settled unreviewed.
506const HELD_FACTS_MAX = 20
507/** Drops the `settled` (key, nonce) entries, then adds `added`, each replacing any entry with its key, and settles the oldest facts past HELD_FACTS_MAX. */
508async function updateHeld($: EngineInterface, settled: readonly string[], added: readonly Held[]): Promise<void> {
509  if (!settled.length && !added.length) return
510  const gone = new Set(settled), keys = new Set(added.map(h => h.key))
511  let list = [...(await readHeld($)).filter(h => !gone.has(token(h)) && !keys.has(h.key)), ...added]
512  const facts = list.filter(h => h.kind === 'fact')
513  if (facts.length > HELD_FACTS_MAX) {
514    const dropped = new Set(facts.slice(0, facts.length - HELD_FACTS_MAX))
515    list = list.filter(h => !dropped.has(h))
516    await logEvent($, 'review-dropped', { reason: `${dropped.size} held fact(s) settled unreviewed past the cap of ${HELD_FACTS_MAX}: ${[...dropped].map(h => h.text).join(' | ')}`.slice(0, 2000) })
517  }
518  if (list.length) await $.store.set(await heldKey($), list)
519  else await $.store.delete(await heldKey($))
520}
521const citedSources = (evidence: readonly Evidence[], sources: ReadonlyMap<string, Source>) =>
522  Object.fromEntries(evidence.flatMap(e => { const src = sources.get(e.ref); return src ? [[e.ref, src]] : [] }))
523
524/** A held claim ready for review, its evidence re-checked against the stored rows. */
525type HeldClaim = { held: Held; issue?: Issue; evidence: Evidence[] }
526
527/** Resolves the held entries: a step needs this session's still-open issue, and any entry needs evidence that still holds; the rest are settled without a claim. */
528function heldClaims(list: readonly Held[], issues: readonly Issue[], session: string): { steps: HeldClaim[]; facts: HeldClaim[]; settled: string[] } {
529  const steps: HeldClaim[] = [], facts: HeldClaim[] = [], settled: string[] = []
530  for (const h of list) {
531    const { held } = checkEvidence(h.evidence, new Map(Object.entries(h.sources)), undefined, h.kind === 'step')
532    const issue = h.kind === 'step' ? issues.find(i => i.id === h.issue && i.origin.session === session) : undefined
533    if (!held.length || (h.kind === 'step' && (!issue || issue.status === 'done' || issue.status === 'dropped')) || (h.kind === 'fact' && !h.text)) settled.push(token(h))
534    else (h.kind === 'step' ? steps : facts).push({ held: h, issue, evidence: held })
535  }
536  return { steps, facts, settled }
537}
538
539type ReviewInput = {
540  ops: readonly Payload[]; evidence: readonly Evidence[][]; facts: readonly Fact[]; issues: readonly Issue[]
541  steps: readonly HeldClaim[]; heldFacts: readonly HeldClaim[]; range: { text: string; sources: ReadonlyMap<string, Source> }; ratio: number
542}
543type Reviewed = { ops: Payload[]; facts: Fact[]; promoted: Payload[]; hold: Held[]; settled: string[]; failed: boolean }
544// Room for one verdict line per claim, so a large held set cannot truncate the reply into a parse failure.
545const reviewMaxTokens = (claims: number) => Math.min(16_384, Math.max(2048, 512 + 100 * claims))
546// What a failed review still lets land: ops that add a step or move it forward.
547// A note, a retitle, or a move to dropped or question rewrites what the tracker
548// already shows, and nothing has audited it; these carry no evidence for a
549// held entry to keep, so they are dropped and the next fold re-reads any that
550// still hold from the kept turns.
551const landsUnreviewed = (o: Payload) =>
552  o.op === 'create' || o.op === 'progress' || o.op === 'task' || o.op === 'link' || (o.op === 'status' && o.status !== 'dropped' && o.status !== 'question')
553
554/**
555 * A second model call audits the gated ops, the facts and the held claims
556 * against the folded range. A done reaches 한 일 and a fact 핵심 사실·경로 only
557 * on an accept. A rejected or unverdicted done is downgraded; a rejected
558 * non-done op or fact is dropped, and so is an unverdicted fact. A failed or
559 * malformed review downgrades every done, drops the ops `landsUnreviewed`
560 * refuses, holds every fact for the next fold, and reports `failed` so the
561 * caller drops the merge's retracts. A held claim lands on accept, is settled on reject, and stays held
562 * otherwise. The review never blocks the fold.
563 */
564async function reviewFold($: EngineInterface, opts: Opts, input: ReviewInput): Promise<Reviewed> {
565  const title = (id: string) => input.issues.find(i => i.id === id)?.title
566  const withSource = (c: HeldClaim) => Object.fromEntries(c.evidence.map(e => [e.ref, c.held.sources[e.ref]?.text ?? '']))
567  const claims = [
568    ...input.ops.map((o, i) => ({ id: `op${i + 1}`, claim: describeOp(o, 'issue' in o ? title(o.issue) : undefined), evidence: input.evidence[i] ?? [] })),
569    ...input.facts.map((f, i) => ({ id: `fact${i + 1}`, claim: `fact: ${f.text}`, evidence: f.evidence })),
570    ...input.steps.map((c, i) => ({ id: `step${i + 1}`, claim: describeOp({ op: 'status', issue: c.issue!.id, status: 'done' }, c.issue!.title), evidence: c.evidence, source: withSource(c) })),
571    ...input.heldFacts.map((c, i) => ({ id: `held${i + 1}`, claim: `fact: ${c.held.text}`, evidence: c.evidence, source: withSource(c) })),
572  ]
573  let review
574  try {
575    const r = await $.model.complete({
576      model: opts.reviewModel,
577      system: REVIEW_SYSTEM,
578      prompt: [
579        `<claims>\n${claims.map(c => JSON.stringify(c)).join('\n') || '(none)'}\n</claims>`,
580        `<folded_range>\n${input.range.text}\n</folded_range>`,
581      ].join('\n\n'),
582      maxTokens: reviewMaxTokens(claims.length),
583      timeoutMs: 60_000,
584    })
585    review = r.isAnswered ? parseReview(r.text) : `review model gave no text (${r.reason})`
586  } catch (err) {
587    review = `review call failed (${String(err).slice(0, 120)})`
588  }
589  if (typeof review === 'string') {
590    const dones = input.ops.filter(isDoneClaim).length
591    const refused = input.ops.filter(o => !landsUnreviewed(o))
592    const hold: Held[] = []
593    for (const f of input.facts) hold.push({ key: `fact:${instructionKey(f.text)}`, nonce: await newNonce($), kind: 'fact', text: f.text, evidence: f.evidence, sources: citedSources(f.evidence, input.range.sources) })
594    await logEvent($, 'review-failed', { ratio: input.ratio, reason: `${review}; ${dones} done(s) downgraded to doing, ${refused.length} unreviewed op(s) dropped${refused.length ? ` (${refused.map(o => describeOp(o)).join('; ')})` : ''}, retracts dropped, ${input.facts.length} fact(s) and ${input.steps.length + input.heldFacts.length} held claim(s) held for the next fold`.slice(0, 2000) })
595    return { ops: input.ops.filter(landsUnreviewed).map(o => (isDoneClaim(o) ? downgrade(o) : o)), facts: [], promoted: [], hold, settled: [], failed: true }
596  }
597  const verdict = new Map(review.map(v => [v.id, v]))
598  const lines: string[] = []
599  const ops = input.ops.flatMap((o, i): Payload[] => {
600    const v = verdict.get(`op${i + 1}`)
601    if (v?.accept) return [o]
602    if (!v && !isDoneClaim(o)) return [o]
603    const why = v ? v.reason : 'no verdict'
604    lines.push(`op${i + 1} ${describeOp(o)}: ${isDoneClaim(o) ? 'done downgraded to doing' : 'dropped'} (${why})`)
605    return isDoneClaim(o) ? [downgrade(o)] : []
606  })
607  const facts = input.facts.filter((f, i) => {
608    const v = verdict.get(`fact${i + 1}`)
609    if (!v?.accept) lines.push(`fact${i + 1} "${f.text}" dropped (${v ? v.reason : 'no verdict'})`)
610    return v?.accept === true
611  })
612  const promoted: Payload[] = [], settled: string[] = []
613  const judge = (id: string, c: HeldClaim, land: () => void) => {
614    const v = verdict.get(id)
615    if (!v) return void lines.push(`${id} ${c.held.key}: still held (no verdict)`)
616    settled.push(token(c.held))
617    if (v.accept) land()
618    else lines.push(`${id} ${c.held.key}: settled unaccepted (${v.reason})`)
619  }
620  input.steps.forEach((c, i) => judge(`step${i + 1}`, c, () => promoted.push({ op: 'status', issue: c.issue!.id, status: 'done' })))
621  input.heldFacts.forEach((c, i) => judge(`held${i + 1}`, c, () => facts.push({ text: c.held.text!, evidence: c.evidence })))
622  if (lines.length) await logEvent($, 'review-rejected', { ratio: input.ratio, reason: lines.join(' | ').slice(0, 2000) })
623  return { ops, facts, promoted, hold: [], settled, failed: false }
624}
625
626/**
627 * Plans a fold of `rows` and merges the dropped turns into the ledger. Returns
628 * the reason instead when there is nothing to fold, the merge failed and
629 * `mustFold` is off, or the result would not be smaller. Updates `meta.fails`;
630 * the caller writes `meta`.
631 */
632// `calibrate` is false on the escape retry, whose usage reading predates the
633// withheld rows and so would inflate the ratio and overhead.
634async function fold($: EngineInterface, opts: Opts, rows: readonly SessionMessage[], meta: Meta, mustFold: boolean, reading?: number, calibrate = true): Promise<Fold | string> {
635  const observed = freshReal(reading ?? await usageReading($), meta)
636  const calib = calibrate ? observed : undefined
637  const estimated = sum(rows)
638  const overhead = meta.overhead ?? 0
639  // The ratio needs an overhead baseline (system prompt, tools) to subtract;
640  // the first reading only sets that baseline, below.
641  const ratio = calib === undefined || meta.overhead === undefined ? (meta.ratio ?? 1) : clampRatio(calib / Math.max(1, overhead + estimated))
642  if (calib !== undefined) {
643    if (meta.overhead !== undefined) meta.ratio = ratio
644    meta.lastObserved = calib
645  }
646  const prev = (await readText($, await ledgerPath($))) ?? EMPTY_LEDGER
647  // tailTarget counts message tokens, as the budget does; the system prompt and tool schemas are outside it.
648  const plan = planClear(rows, Math.max(0, opts.tailTarget / ratio), Math.ceil(prev.length / 4) + 60)
649  if (!plan) return 'nothing left to fold'
650
651  const here = await captureOrigin($)
652  const view = await trackerView($, here)
653  let notes = notesOf(prev)
654  // Taken before the merge, which may drop them, so a withheld file keeps its pointer.
655  const kept = [...preservedLines(notes), ...withheldLines(plan.dropped)]
656  const storedInstructions = instructionValues(notes)
657  const instructions = storedInstructions.filter(i => !i.startsWith(WITHDRAWN_PREFIX))
658  const priorFacts = factLines(notes)
659  const range = renderRange(plan.dropped)
660  let ops: Payload[] = []
661  let evidence: Evidence[][] = []
662  let facts: Fact[] = []
663  let retracted: string[] = []
664  let withdrawn: string[] = []
665  let fallback = false
666  let promoted: Payload[] = []
667  let hold: Held[] = []
668  const held = heldClaims(await readHeld($), view.issues, here.session)
669  let settled = held.settled
670  if (plan.dropped.length > 0) {
671    const legacy = legacyItems(prev)
672    const r = await $.model.complete({
673      model: opts.model,
674      system: MERGE_SYSTEM,
675      prompt: [
676        `<notes>\n${mergeableNotes(notes)}\n</notes>`,
677        `<instructions>\n${instructions.map(i => JSON.stringify(i)).join('\n') || '(none)'}\n</instructions>`,
678        `<facts>\n${priorFacts.join('\n') || '(none)'}\n</facts>`,
679        `<issues>\n${issueList(view.issues)}\n</issues>`,
680        ...(view.finished.length ? [`<finished_earlier>\n${view.finished.map(i => `- ${i.title}`).join('\n')}\n</finished_earlier>`] : []),
681        ...(legacy.length ? [`<legacy>\n${legacy.join('\n')}\n</legacy>`] : []),
682        `<removed_turns>\n${range.text}\n</removed_turns>`,
683      ].join('\n\n'),
684      maxTokens: 4096,
685      timeoutMs: 120_000,
686    })
687    const withdrawable = [...instructions, ...plan.dropped.filter(isUserTyped).map(m => stripReminders(m.text))]
688    const merged = r.isAnswered ? parseMerge(r.text, new Set(view.issues.map(i => i.id)), Math.max(500, Math.floor(opts.budget / 4)), withdrawable, priorFacts) : 'merge model gave no text (' + r.reason + ')'
689    const bad = typeof merged === 'string' ? merged : undefined
690    if (typeof merged !== 'string') {
691      notes = withNotes(notes, merged.notes)
692      ;({ ops, evidence, facts, retracted, withdrawn } = merged)
693      meta.fails = 0
694      if (merged.rejected) await logEvent($, 'ops-rejected', { ratio, reason: `${merged.rejected} op(s) failed validation; ${ops.length} kept` })
695      const gated = await gateMerge($, ops, evidence, facts, range.sources, ratio)
696      ;({ ops, evidence, facts } = gated)
697    } else {
698      meta.fails += 1
699      await logEvent($, !r.isAnswered && r.reason === 'aborted' ? 'merge-timeout' : 'merge-invalid', { ratio, reason: `${bad} (failure ${meta.fails} in a row)` })
700      if (!mustFold && meta.fails < FAILS_BEFORE_FALLBACK) return `merged ledger rejected: ${bad}`
701      // A merge that never succeeds must not block folding forever.
702      ;(notes = fallbackLedger(notes, plan.dropped)), (ops = [])
703      evidence = ops.map(() => [])
704      fallback = true
705      meta.fails = 0
706      $.ui.log(`clm: merge failed (${bad}); folded with a mechanical ledger instead`, { to: 'debug' })
707      await logEvent($, 'fallback', { ratio, reason: bad })
708    }
709    // The fallback's ops claim no done, so only the merge's claims and the held step dones go to review.
710    const reviewed = await reviewFold($, opts, {
711      ops: fallback ? [] : ops, evidence: fallback ? [] : evidence, facts, issues: view.issues, steps: held.steps, heldFacts: held.facts, range, ratio,
712    })
713    if (!fallback) ops = reviewed.ops
714    // A retract deletes a fact a reviewer once accepted; with no verdict on it, the fact stays.
715    if (reviewed.failed) retracted = []
716    facts = reviewed.facts
717    promoted = reviewed.promoted
718    hold = reviewed.hold
719    settled = [...settled, ...reviewed.settled]
720  }
721  // Backstop for the merge model: a step already recorded as done is never created twice.
722  ops = ops.filter(o => !(o.op === 'create' && o.status === 'done' && view.doneTitles.has(o.title.trim())))
723  notes = rememberFacts(notes, facts, retracted)
724  const preserved = preserveInstructions(notes, storedInstructions, plan.dropped, await ledgerPath($), withdrawn)
725  notes = rememberPreserved(rememberOversize(preserved.notes, oversizeLines(plan.tail, plan.cuts)), kept)
726  const fullNotes = rememberPreserved(rememberOversize(preserved.fullNotes, oversizeLines(plan.tail, plan.cuts)), kept)
727
728  const seq = meta.seq + 1
729  const currentView = await trackerView($, here, [...ops, ...promoted])
730  const text = ledgerRowText(seq, new Date().toISOString(), await ledgerPath($), composeLedger(notes, currentView.issues))
731  const tokensBefore = messageTokens(rows, observed, meta)
732  const tokensAfter = sum(buildCleared(plan, { role: 'user', text, toolUses: [] }))
733  if (tokensAfter >= tokensBefore) {
734    const why = `the fold would not shrink the context (${tokensBefore} -> ${tokensAfter} est. tokens)`
735    $.ui.log(`clm: ${why}`)
736    await logEvent($, 'skip-not-shrinking', { tokensBefore, tokensAfter, keptTurns: plan.keptTurns, ratio, reason: why })
737    return why
738  }
739  if (Object.keys(plan.cuts).length)
740    await logEvent($, 'truncation', { keptTurns: plan.keptTurns, ratio, reason: `cut ${Object.keys(plan.cuts).length} tool result(s) to ~${Object.values(plan.cuts)[0]}t each` })
741  if (calib !== undefined) meta.overhead = Math.max(0, calib - estimated)
742  return { plan, notes, fullNotes, ops, seq, fallback, promoted, hold, settled }
743}
744
745async function maybeClear($: EngineInterface, opts: Opts) {
746  const meta = await readMeta($)
747  const rows = liveRows(await $.session.messages(), meta.boundary)
748  const reading = await usageReading($)
749  const observed = freshReal(reading, meta)
750  // Before any fold has set the overhead baseline, a reading's excess over the
751  // rows' estimate is taken as system prompt and tool schemas.
752  const used = messageTokens(rows, observed, { overhead: meta.overhead ?? (observed === undefined ? undefined : Math.max(0, observed - sum(rows))) })
753  // The tail target is the lower post-fold bound; wait until the context is
754  // near the ceiling before folding again, so suffix-cache busts stay rare.
755  const trigger = Math.max(opts.budget - opts.reserve, Math.floor(opts.budget * HYSTERESIS_FLOOR))
756  if (used <= trigger) return
757  const startedAt = await $.clock.now()
758  const f = await fold($, opts, rows, meta, false, reading)
759  const foldMs = Math.max(0, (await $.clock.now()) - startedAt)
760  await writeMeta($, meta)
761  if (typeof f === 'string') return skip($, f)
762  const pending: Pending = {
763    notes: f.notes, fullNotes: f.fullNotes, ops: f.ops, seq: f.seq, keepFp: f.plan.tail[0] && fingerprint(f.plan.tail[0]),
764    keepFrom: rows.length - f.plan.tail.length, dropFps: f.plan.dropped.map(fingerprint),
765    cuts: f.plan.cuts, keptTurns: f.plan.keptTurns, fallback: f.fallback, foldMs, promoted: f.promoted, hold: f.hold, settled: f.settled,
766  }
767  await $.store.set(await pendingKey($), pending)
768  try {
769    // Answered by this plugin's own session.compact hook below, which applies
770    // the pending plan; the engine summarizer is never reached.
771    await $.session.compact({})
772  } catch (err) {
773    $.ui.log(`clm: fold deferred to the next compaction (${String(err).slice(0, 160)})`, { to: 'debug' })
774  }
775}
776
777async function showTurnChanges($: EngineInterface, e: { answer: string }) {
778  if (!e.answer.trim()) return
779  const session = await sid($)
780  const meta = await readMeta($)
781  const rows = liveRows(await $.session.messages(), meta.boundary)
782  const hasToolUse = rows.slice(-8).some(m => m.toolUses.length > 0 || (m.toolResults?.length ?? 0) > 0)
783  if (!hasToolUse && e.answer.trim().length < 200) return
784  try {
785    const stored = notesOf((await readText($, await ledgerPath($))) ?? EMPTY_LEDGER)
786    const ledger = `${mergeableNotes(stored)}\n\n## 핵심 사실·경로\n${factLines(stored).map(l => `- ${l}`).join('\n') || '- (none yet)'}`
787    // clm-prompt
788    const prompt = `<ledger_goal_and_key_facts>\n${ledger}\n</ledger_goal_and_key_facts>\n<finished_turn>\n${renderTurns(rows.slice(-8), 20000)}\n</finished_turn>`
789    const r = await $.model.complete({ model: 'haiku', system: TURN_CHANGE_SYSTEM, prompt, maxTokens: 300, timeoutMs: 15000 })
790    if (!r.isAnswered) return
791    const seen = shownChanges.get(session) ?? new Set<string>()
792    shownChanges.set(session, seen)
793    for (const line of parseChanges(r.text)) {
794      const key = changeHash(line)
795      if (seen.has(key)) continue
796      seen.add(key)
797      $.ui.log('맥락 갱신: ' + line)
798    }
799  } catch (err) {
800    $.ui.log(`clm: turn context refresh failed (${String(err).slice(0, 160)})`, { to: 'debug' })
801  }
802}
803
804// --- per-tool-call steps ----------------------------------------------------
805
806// Each main-loop tool call moves the tracker, and through append the notices
807// and the task panel, while the turn runs; the fold no longer holds every step
808// back until it lands. The tool.call hook only schedules this: the model call
809// runs detached, one at a time per session, and calls arriving meanwhile
810// collapse into one rerun over everything after the watermark.
811// clm-prompt
812export const STEP_SYSTEM = [
813  'You keep the task tracker of a coding session up to date while it works.',
814  'You receive the tracker\'s issues, the session rows added since your last update, and the tool call that just finished.',
815  'A user message is headed `[user mN]` and a tool result `<- [id]`; mN and id are refs you cite. Assistant rows carry no ref: they show what the assistant said or proposed, and the tool results and the user\'s words show what actually happened.',
816  'Reply with `<ops>` holding a JSON array of tracker changes those rows show, and `</ops>`, nothing else. Each op is one of:',
817  '  {"op":"create","title":"<one line>","status":"todo|doing|done|question","note":"<one line>","evidence":[{"ref":"<ref>","quote":"<text copied exactly from that row>"}]}',
818  '  {"op":"status","issue":"<id from the issues>","status":"todo|doing|done|question|dropped","evidence":[{"ref":"<ref>","quote":"<text copied exactly from that row>"}]}',
819  '  {"op":"progress","issue":"<id from the issues>","done":<integer>,"total":<positive integer>}',
820  '  {"op":"note","issue":"<id from the issues>","text":"<one line>"}',
821  'Every done carries evidence: the ref of a tool result or user message showing the state-changing action ran, and a quote copied character for character from that row. A step the assistant proposed or recommended, and a read-only check of the current state, is todo or question.',
822  'Create an issue for each step the assistant announced (todo), started (doing) or finished (done, with evidence), and each question waiting for the user (question).',
823  'Move an existing issue with a status op once the rows show it changed; never re-create it.',
824  'Write `<ops>[]</ops>` when no task changed.',
825].join('\n')
826const steppedKey = async ($: EngineInterface) => `stepped:${await sid($)}`
827// The finished call, rendered, and its result as a citable source when the call carries an id.
828type StepCall = { text: string; source?: [string, Source] }
829const stepRuns = new Map<string, { queued?: StepCall }>()
830
831const renderCall = ({ tool, agentId: _a, tool_use_id: id, ...input }: { tool: string; agentId?: string; tool_use_id?: string }, r: { deny?: string; isError?: boolean; text?: string; result?: unknown }): StepCall => {
832  const out = (r.deny !== undefined ? `denied: ${r.deny}` : `${r.isError ? 'error ' : ''}${r.text ?? JSON.stringify(r.result ?? null)}`).slice(0, 1500)
833  const text = `-> ${tool} ${JSON.stringify(input).slice(0, 600)}\n<- ${id ? `[${id}] ` : ''}${out}`
834  return id && r.deny === undefined ? { text, source: [id, { kind: 'tool_result', text: out, tool, isError: r.isError === true }] } : { text }
835}
836
837async function stepOnce($: EngineInterface, call: StepCall): Promise<void> {
838  const meta = await readMeta($)
839  const rows = liveRows(await $.session.messages(), meta.boundary)
840  const key = await steppedKey($)
841  const mark = await $.store.get(key)
842  const fresh = rows.slice(typeof mark === 'string' ? rows.map(fingerprint).lastIndexOf(mark) + 1 : 0)
843  const here = await captureOrigin($)
844  const view = await trackerView($, here)
845  const range = renderRange(fresh, 20000)
846  const sources = new Map(range.sources)
847  if (call.source) sources.set(...call.source)
848  const r = await $.model.complete({
849    model: 'haiku',
850    system: STEP_SYSTEM,
851    prompt: [`<issues>\n${issueList(view.issues)}\n</issues>`, `<new_rows>\n${range.text}\n</new_rows>`, `<tool_call>\n${call.text}\n</tool_call>`].join('\n\n'),
852    maxTokens: 1024,
853    timeoutMs: 30_000,
854  })
855  const body = r.isAnswered ? /<ops>([\s\S]*?)<\/ops>/.exec(r.text)?.[1] : undefined
856  const ops = body === undefined ? undefined : parseOps(normalizeLedger(body) || '[]', new Set(view.issues.map(i => i.id)))
857  if (ops === undefined || typeof ops === 'string') {
858    $.ui.log(`clm steps: no update (${r.isAnswered ? (ops ?? 'reply has no <ops>') : r.reason})`, { to: 'debug' })
859    return
860  }
861  const gated = gateOps(ops.ops, ops.evidence, sources)
862  if (gated.rejections.length) await logEvent($, 'evidence-rejected', { reason: `step: ${gated.rejections.join(' | ')}`.slice(0, 2000) })
863  // No reviewer runs here, so a done that passed the mechanical check is recorded as doing and held for the next fold's review.
864  const held: { issue?: string; title?: string; evidence: Evidence[]; sources: Record<string, Source> }[] = []
865  const stepOps = gated.ops.map((o, i) => {
866    if (!isDoneClaim(o) || (o.op !== 'create' && o.op !== 'status')) return o
867    const evidence = gated.evidence[i] ?? []
868    held.push({ ...(o.op === 'status' ? { issue: o.issue } : { title: o.title.trim() }), evidence, sources: citedSources(evidence, sources) })
869    return downgrade(o)
870  })
871  const events = await append($, stepOps, here, true, true)
872  if (held.length) {
873    // A held create is keyed by the issue id it created, read off the event
874    // append wrote; a create deduped against an existing title resolves to that
875    // session's newest open issue of the title, and is dropped when none is open.
876    const open = held.some(h => h.title !== undefined) ? await sessionIssues($, {}) : []
877    const entries: Held[] = []
878    for (const h of held) {
879      const issue = h.issue
880        ?? events.find(ev => ev.op === 'create' && ev.title.trim() === h.title)?.issue
881        ?? open.filter(x => x.title.trim() === h.title && x.status !== 'done' && x.status !== 'dropped').at(-1)?.id
882      if (issue) entries.push({ key: `issue:${issue}`, nonce: await newNonce($), kind: 'step', issue, evidence: h.evidence, sources: h.sources })
883    }
884    await updateHeld($, [], entries)
885  }
886  // Advanced only once the ops landed, so a fold covers whatever a failed step left out.
887  const last = rows.at(-1)
888  if (last) await $.store.set(key, fingerprint(last))
889}
890
891async function trackSteps($: EngineInterface, call: StepCall): Promise<void> {
892  const session = await sid($)
893  const running = stepRuns.get(session)
894  if (running) {
895    running.queued = call
896    return
897  }
898  const run: { queued?: StepCall } = {}
899  stepRuns.set(session, run)
900  try {
901    for (let next: StepCall | undefined = call; next !== undefined; next = run.queued) {
902      run.queued = undefined
903      await stepOnce($, next)
904    }
905  } finally {
906    stepRuns.delete(session)
907  }
908}
909
910/** True when every row the fold drops lies at or before the per-tool-call watermark, so its steps were already emitted. */
911async function steppedThrough($: EngineInterface, rows: readonly SessionMessage[], keepFrom: number): Promise<boolean> {
912  const mark = await $.store.get(await steppedKey($))
913  return typeof mark === 'string' && rows.map(fingerprint).lastIndexOf(mark) >= keepFrom - 1
914}
915
916// The engine summarizer, not clm, folds the rows on the escape path, so their
917// prompts would reach the next clm fold only as the summary's paraphrase.
918/** Records every user prompt in `rows` under 사용자 지시 in the ledger file, which the next fold reads as its prior instructions. */
919async function keepEscapedInstructions($: EngineInterface, rows: readonly SessionMessage[]): Promise<void> {
920  try {
921    const path = await ledgerPath($)
922    const notes = notesOf((await readText($, path)) ?? EMPTY_LEDGER)
923    const preserved = preserveInstructions(notes, instructionValues(notes), rows.slice(protectedIndex(rows)), path)
924    const view = await trackerView($, await captureOrigin($))
925    await $.fs.write(path, composeLedger(preserved.fullNotes, view.issues) + '\n')
926  } catch (err) {
927    await logEvent($, 'escape-instructions-lost', { reason: String(err).slice(0, 160) })
928  }
929}
930
931const WITHHELD = '.withheld-'
932
933// The files stay while a row or the ledger file points at them, so a resumed
934// session can still read them; once neither does, nothing can reach them.
935/** Removes this session's withheld-result files that neither a row in `live` nor `ledger` names. */
936async function pruneWithheld($: EngineInterface, live: readonly SessionMessage[], ledger = ''): Promise<void> {
937  try {
938    const base = await basePath($)
939    const dir = base.slice(0, base.lastIndexOf('/'))
940    const prefix = base.slice(dir.length + 1) + WITHHELD
941    if (!(await $.fs.exists(dir))) return
942    const pointed = [ledger, ...live.flatMap(m => (m.toolResults ?? []).map(r => r.text))].join('\n')
943    const stale = (await $.fs.list(dir))
944      .filter(f => f.kind === 'file' && f.name.startsWith(prefix) && !pointed.includes(`${dir}/${f.name}`))
945      .map(f => `${dir}/${f.name}`)
946    if (stale.length === 0) return
947    const r = await $.process.run(['rm', '-f', '--', ...stale], { timeoutMs: 5000 })
948    if (r.exitCode !== 0) $.ui.log(`clm escape: could not remove ${stale.length} withheld file(s) (exit ${r.exitCode}: ${r.stderr.slice(0, 160)})`, { to: 'debug' })
949  } catch (err) {
950    $.ui.log(`clm escape: withheld-file cleanup failed (${String(err).slice(0, 160)})`, { to: 'debug' })
951  }
952}
953
954// After a failed fold, the oldest tool results move to files, one at a time,
955// until the rows fit the budget; each row keeps a pointer to its file. A
956// changed row is rebuilt without its handle, which would map it back to the
957// engine's original message.
958async function withholdOldestResults($: EngineInterface, rows: readonly SessionMessage[], opts: Opts): Promise<readonly SessionMessage[] | undefined> {
959  if (!rows.some(m => m.toolResults?.length)) return undefined
960  await pruneWithheld($, rows, (await readText($, await ledgerPath($))) ?? '')
961  const current = [...rows]
962  const limit = opts.budget - opts.reserve
963  const base = await basePath($)
964  let n = 0
965  for (const [rowIndex, row] of rows.entries()) {
966    let updated = row
967    for (const result of row.toolResults ?? []) {
968      const index = n++
969      if (result.text.startsWith('[clm escape:')) continue
970      const path = `${base}${WITHHELD}${index}-${result.tool_use_id.replace(/[^a-zA-Z0-9_.-]/g, '_')}.txt`
971      try {
972        await $.fs.write(path, result.text)
973      } catch (err) {
974        $.ui.log('clm escape: could not preserve tool result at ' + path + ' (' + String(err).slice(0, 160) + ')', { to: 'debug' })
975        continue
976      }
977      updated = {
978        role: updated.role, text: updated.text, toolUses: updated.toolUses,
979        toolResults: (updated.toolResults ?? []).map(r => (r.tool_use_id === result.tool_use_id ? { ...r, text: withheldPointer(path) } : r)),
980      }
981      current[rowIndex] = updated
982      if (sum(current) <= limit) return current
983    }
984  }
985  return current
986}
987
988export function report(ledger: string | undefined, rows: readonly SessionMessage[], opts: Opts, meta: Meta, logTail: readonly string[]): string {
989  const used = Math.round(visibleTokens(rows) * (meta.ratio ?? 1))
990  return [
991    `clm budget: ${used}/${opts.budget} tokens (${Math.round((used / opts.budget) * 100)}%); folds above ${opts.budget - opts.reserve}, tail target ${opts.tailTarget}, ratio ${meta.ratio ?? 1}`,
992    `rows: ${rows.filter(m => !isSystemRow(m)).length} kept, ${rows.filter(isSystemRow).length} hidden system rows`,
993    `last fold: ${meta.lastClear ?? 'never'} (ledger #${meta.seq})${meta.lastSkip ? `; last skip: ${meta.lastSkip}` : ''}`,
994    '',
995    ledger ?? '(no ledger yet)',
996    '',
997    'recent decisions:',
998    ...(logTail.length ? logTail : ['(none)']),
999  ].join('\n')
1000}
1001
1002// clm-prompt
1003const GUIDE = [
1004  'Memory ledger (clm): when this conversation outgrows its budget, the harness folds the older turns into one row that starts with "[clm ledger".',
1005  'That row is a Haiku summary: the goal, the user\'s instructions verbatim, what was done, what is left, open questions and key facts, each done step and key fact citing a quote from the user or a tool result. The turns it replaced are gone.',
1006  'Use it as your memory of that work, and re-query any PR, branch, deploy or ticket state before acting on it or reporting it.',
1007  'Describe task changes in the conversation; clm records them in the tracker and task panel.',
1008].join('\n')
1009
1010export const register: Register = (on, options) => {
1011  const { opts, problems } = readOpts(options as Record<string, unknown>)
1012
1013  on('engine.create', async ($, e, next) => {
1014    const built = await next(e)
1015    return {
1016      ...built,
1017      clm: {
1018        track: () => { throw new Error('clm.track is handled by the clm plugin') },
1019        issues: () => { throw new Error('clm.issues is handled by the clm plugin') },
1020      },
1021    }
1022  })
1023
1024  on('clm.track', async ($, e) => ({ value: await trackIssue($, e) }))
1025  on('clm.issues', async ($, e) => ({ value: await sessionIssues($, e ?? {}) }))
1026
1027  on('session.start', async ($, e, next) => {
1028    for (const p of problems) $.ui.log(`clm: option ${p}`)
1029    await $.command.register({
1030      name: 'clm',
1031      description: 'Show the clm ledger, budget use and recent decisions; `board` opens the issue board in the browser, `link <id>` brings another repo\'s issue into this session (shown to you only).',
1032      immediate: true,
1033    })
1034    return next(e)
1035  })
1036
1037  // Printed through ui.log, which the engine draws as a notice and never sends
1038  // to the model; a command's `text` is a transcript row and may be.
1039  on('command.run', { command: 'clm' }, async ($, e) => {
1040    // `$.command.run` documents a left-out args as "", yet the test kit hands it over undefined.
1041    const [sub, arg] = (e.args ?? '').trim().split(/\s+/)
1042    if (sub === 'board') {
1043      await openBoard($)
1044      return {}
1045    }
1046    if (sub === 'link') {
1047      const issue = arg ? snapshot(await readAll($)).find(i => i.id === arg) : undefined
1048      if (!issue) {
1049        $.ui.log(arg ? `clm: no issue ${arg}; /clm board lists them` : 'clm: usage /clm link <issue id>')
1050        return {}
1051      }
1052      await append($, [{ op: 'link', issue: issue.id }])
1053      $.ui.log(`clm: ${issue.id} "${issue.title}" (${issue.origin.repo}) is linked to this session and shows in its ledger from the next fold`)
1054      return {}
1055    }
1056    if (sub) {
1057      $.ui.log('clm: usage /clm, /clm board, /clm link <issue id>')
1058      return {}
1059    }
1060    const meta = await readMeta($)
1061    const rows = liveRows(await $.session.messages(), meta.boundary)
1062    const log = ((await readText($, await logPath($))) ?? '').split('\n').filter(Boolean).slice(-5)
1063    $.ui.log(report(await readText($, await ledgerPath($)), rows, opts, meta, log))
1064    return {}
1065  })
1066
1067  on('turn.complete', async ($, e, next) => {
1068    const res = await next(e)
1069    if (e.agentId !== undefined) return res
1070    try {
1071      void showTurnChanges($, e).catch(err => $.ui.log(`clm: turn context refresh failed (${String(err).slice(0, 160)})`, { to: 'debug' }))
1072      await maybeClear($, opts)
1073    } catch (err) {
1074      $.ui.log(`clm: fold failed, ${String(err).slice(0, 200)}`, { to: 'debug' })
1075    }
1076    return res
1077  })
1078
1079  // The main conversation's every compaction ends here and never reaches
1080  // next(e), the engine summarizer: a pending plan is applied, otherwise the
1081  // fold is planned and merged on the spot. Subagents keep the engine's.
1082  on('session.compact', async ($, e, next) => {
1083    if (e.agentId !== undefined) return next(e)
1084    if (e.trigger === 'precompute') return { skip: 'clm replaces compaction' }
1085    // Without a transcript there is nothing to rewrite; a pending plan waits.
1086    if (!Array.isArray(e.messages)) return { skip: 'clm: the compaction carried no transcript' }
1087    const compactStartedAt = await $.clock.now()
1088    const all = e.messages
1089    const meta = await readMeta($)
1090    let workingRows = liveRows(all, meta.boundary)
1091    let first = protectedIndex(workingRows)
1092    const key = await pendingKey($)
1093    const stored = await $.store.get(key)
1094    const pending = isPending(stored) ? stored : undefined
1095    if (stored !== undefined) await $.store.delete(key)
1096
1097    let use: (Omit<Pending, 'keepFp' | 'keepFrom' | 'dropFps'> & { keepFrom: number }) | undefined
1098    if (pending) {
1099      const keepFrom = pending.keepFrom
1100      const row = keepFrom < workingRows.length ? workingRows[keepFrom] : undefined
1101      const expectedDropped = workingRows.slice(first + 1, keepFrom).filter(m => !isLedgerRow(m) && !isSystemRow(m)).map(fingerprint)
1102      const sameDropped = expectedDropped.length === pending.dropFps.length
1103        && expectedDropped.every((fp, i) => fp === pending.dropFps[i])
1104      const sameKept = pending.keepFp === undefined
1105        ? keepFrom === workingRows.length
1106        : row !== undefined && fingerprint(row) === pending.keepFp
1107      if (keepFrom >= first + 1 && keepFrom <= workingRows.length && sameKept && sameDropped) use = { ...pending, keepFrom }
1108    }
1109    let escaped = false
1110    if (!use) {
1111      let f: Fold | string
1112      try {
1113        f = await fold($, opts, workingRows, meta, true)
1114      } catch (err) {
1115        f = 'fold failed: ' + String(err).slice(0, 160)
1116      }
1117      if (typeof f === 'string' && (e.trigger === 'auto' || e.trigger === 'manual')) {
1118        const prefix = all.slice(0, all.length - workingRows.length)
1119        const escapedRows = await withholdOldestResults($, workingRows, opts)
1120        if (escapedRows) {
1121          escaped = true
1122          workingRows = escapedRows
1123          first = protectedIndex(workingRows)
1124          try {
1125            f = await fold($, opts, workingRows, meta, true, undefined, false)
1126          } catch (err) {
1127            f = 'escape retry failed: ' + String(err).slice(0, 160)
1128          }
1129        }
1130        if (typeof f === 'string') {
1131          await logEvent($, 'escape', { reason: e.trigger + ': ' + f })
1132          await keepEscapedInstructions($, workingRows)
1133          // The engine summarizes the withheld rows, so the escape still shrinks what it reads.
1134          return next(escapedRows ? { ...e, messages: [...prefix, ...escapedRows] } : e)
1135        }
1136      }
1137      await writeMeta($, meta)
1138      if (typeof f === 'string') {
1139        $.ui.log(`clm: ${e.trigger} compaction left the conversation as it is: ${f}`)
1140        return { messages: all }
1141      }
1142      use = { notes: f.notes, fullNotes: f.fullNotes, ops: f.ops, seq: f.seq, cuts: f.plan.cuts, keptTurns: f.plan.keptTurns, fallback: f.fallback, keepFrom: workingRows.length - f.plan.tail.length, foldMs: 0, promoted: f.promoted, hold: f.hold, settled: f.settled }
1143    }
1144
1145    const here = await captureOrigin($)
1146    const covered = await steppedThrough($, workingRows, use.keepFrom)
1147    // A held step done the reviewer accepted lands whether or not the step path covered the fold's turns.
1148    const appended = await append($, [...(covered ? [] : use.ops), ...(use.promoted ?? [])], here, false, true)
1149    await updateHeld($, use.settled ?? [], use.hold ?? [])
1150    const currentView = await trackerView($, here)
1151    const ledger = composeLedger(use.notes, currentView.issues)
1152    const fileLedger = composeLedger(use.fullNotes, currentView.issues)
1153    const text = ledgerRowText(use.seq, new Date().toISOString(), await ledgerPath($), ledger)
1154    const ledgerRow: SessionMessage = { role: 'user', text, toolUses: [] }
1155    const messages = buildCleared({ head: workingRows.slice(0, first + 1), tail: workingRows.slice(use.keepFrom), cuts: use.cuts }, ledgerRow)
1156    const tokensBefore = sum(workingRows)
1157    const tokensAfter = sum(messages)
1158    await $.fs.write(await ledgerPath($), fileLedger + '\n')
1159    // On the escape path the pre-fold rows still name every withheld file;
1160    // otherwise a file named by neither a kept row nor the ledger is unreachable.
1161    await pruneWithheld($, escaped ? workingRows : messages, fileLedger)
1162    const offset = messages.findIndex(m => m.text === text)
1163    const foldedAt = await $.clock.now()
1164    await writeMeta($, {
1165      ...meta, seq: use.seq, lastClear: new Date().toISOString(), lastFoldAt: foldedAt,
1166      foldDurationsMs: { ...meta.foldDurationsMs, [String(use.seq)]: (use.foldMs ?? 0) + Math.max(0, foldedAt - compactStartedAt) },
1167      foldTokens: await usageReading($), lastSkip: undefined, boundary: { fp: fingerprint(ledgerRow), offset },
1168    })
1169    await logEvent($, 'clear', { tokensBefore, tokensAfter, keptTurns: use.keptTurns, ratio: meta.ratio ?? 1, reason: `${e.trigger}${use.fallback ? ', fallback ledger' : ''}` })
1170    await projectPanel($, here, appended)
1171    return { messages, tokensBefore, tokensAfter }
1172  })
1173
1174  on('tool.call', async ($, e, next) => {
1175    const r = await next(e)
1176    if (e.agentId === undefined && next.origin.plugin !== 'clm' && e.tool !== 'TaskCreate' && e.tool !== 'TaskUpdate') {
1177      const call = renderCall(e, r)
1178      void trackSteps($, call).catch(err => $.ui.log(`clm steps: ${String(err).slice(0, 160)}`, { to: 'debug' }))
1179    }
1180    return r
1181  })
1182  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => (e.agentId !== undefined || next.origin.plugin === 'clm' ? next(e) : denyModelTask($, e)))
1183  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => (e.agentId !== undefined || next.origin.plugin === 'clm' ? next(e) : denyModelTask($, e)))
1184  on('tool.describe', { tool: 'TaskCreate' }, ($, e) => ({ ...e, isDeferred: true }))
1185  on('tool.describe', { tool: 'TaskUpdate' }, ($, e) => ({ ...e, isDeferred: true }))
1186  on('prompt.attachment', { type: 'todo_reminder' }, () => ({ text: null }))
1187
1188  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
1189    const seq = ledgerRowSeq(e.props.text)
1190    if (e.props.isExpanded || e.props.from !== undefined || e.props.task !== undefined || seq === undefined) return next(e)
1191    const durationMs = (await readMeta($)).foldDurationsMs?.[seq]
1192    const text = durationMs === undefined ? '* clm compacted' : `* clm compacted in ${(durationMs / 1000).toFixed(1)} s`
1193    const { Text } = $.ui.resolve(e)
1194    return Text({ children: [text] })
1195  })
1196
1197  on('prompt.compose', async ($, e, next) => {
1198    const r = await next(e)
1199    if (e.traits.includes('bare')) return r
1200    return { ...r, sections: [...r.sections, { id: 'clm:guide', text: GUIDE, scope: 'session' as const }] }
hooks/board.ts 74 lines
1import type { ClmBoardIssue } from '../types'
2
3// `/clm board` writes this page and opens it in the browser. Everything is
4// embedded, so the page works from a file:// URL with no network.
5
6const COLUMNS = [['todo', 'To do'], ['doing', 'Doing'], ['done', 'Done'], ['question', 'Question']] as const
7
8export const shortRepo = (repo: string) => repo.split('/').slice(-2).join('/')
9
10const esc = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
11
12const CSS = `
13:root{--bg:#fafafa;--fg:#1b1b1f;--dim:#6b6b76;--card:#fff;--line:#dcdce2;--accent:#3b5bdb}
14@media (prefers-color-scheme:dark){:root{--bg:#16161a;--fg:#e8e8ee;--dim:#9a9aa8;--card:#202026;--line:#34343c;--accent:#7c93ff}}
15*{box-sizing:border-box}
16body{margin:0;padding:16px;background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,sans-serif}
17header{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-bottom:16px}
18h1{font-size:18px;margin:0 8px 0 0}
19.toggle button{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);padding:4px 12px;cursor:pointer}
20.toggle button:first-child{border-radius:6px 0 0 6px}.toggle button:last-child{border-radius:0 6px 6px 0;border-left:0}
21.toggle button[aria-pressed=true]{background:var(--accent);border-color:var(--accent);color:#fff}
22.meta{color:var(--dim);font-size:12px}
23main{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;align-items:start}
24section h2{font-size:14px;margin:0 0 8px}
25.card{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:8px 10px;margin-bottom:8px;overflow-wrap:anywhere}
26.card .sub{color:var(--dim);font-size:12px}
27.empty{color:var(--dim)}
28[hidden]{display:none!important}
29`
30
31const SCRIPT = `
32const here = document.body.dataset.repo;
33const buttons = document.querySelectorAll('.toggle button');
34function apply(mode) {
35  buttons.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.mode === mode)));
36  document.querySelectorAll('section').forEach(sec => {
37    let n = 0;
38    sec.querySelectorAll('.card').forEach(c => {
39      const show = mode === 'all' || c.dataset.repo === here;
40      c.hidden = !show;
41      if (show) n++;
42    });
43    sec.querySelector('.count').textContent = n;
44    sec.querySelector('.empty').hidden = n !== 0;
45  });
46}
47buttons.forEach(b => b.addEventListener('click', () => apply(b.dataset.mode)));
48apply('repo');
49`
50
51function card(i: ClmBoardIssue): string {
52  const where = [esc(shortRepo(i.repo)), ...(i.branch ? [esc(i.branch)] : [])].join(' · ')
53  return `<div class="card" data-repo="${esc(i.repo)}"><div>${esc(i.title)}</div>`
54    + `<div class="sub">${esc(i.id)} · ${where}</div><div class="sub">updated <time>${esc(i.updated)}</time></div></div>`
55}
56
57export function boardHtml(issues: ClmBoardIssue[], repo: string, generatedAt: string): string {
58  const columns = COLUMNS.map(([status, label]) => {
59    const items = issues.filter(i => i.status === status).sort((x, y) => (x.updated < y.updated ? 1 : -1))
60    return `<section><h2>${label} (<span class="count">${items.length}</span>)</h2>${items.map(card).join('')}<div class="empty">-</div></section>`
61  }).join('')
62  return `<!doctype html>
63<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
64<title>clm board</title><style>${CSS}</style></head>
65<body data-repo="${esc(repo)}">
66<header><h1>clm board</h1>
67<div class="toggle"><button data-mode="repo" aria-pressed="true">this repo (${esc(shortRepo(repo))})</button><button data-mode="all" aria-pressed="false">all repos</button></div>
68<span class="meta">generated ${esc(generatedAt)} · run /clm board to regenerate</span></header>
69<main>${columns}</main>
70<script>${SCRIPT}</script>
71</body></html>
72`
73}
74
hooks/fold.ts 157 lines
1import type { SessionMessage } from 'claude-code'
2
3// Matches the header of rows written before the Haiku-summary wording too.
4const LEDGER_ROW = /^\[clm ledger #(\d+) · [^\]]+\] Earlier turns of this session were folded into these notes by /
5const RESULT_FLOOR = 1024 // tokens each older kept tool result may shrink to, at least
6
7// --- rows ----------------------------------------------------------------
8
9export const estTokens = (m: SessionMessage): number => {
10  let chars = m.text.length
11  for (const u of m.toolUses) chars += JSON.stringify(u.input ?? {}).length + u.tool.length
12  for (const r of m.toolResults ?? []) chars += r.text.length
13  return Math.ceil(chars / 4)
14}
15export const sum = (rows: readonly SessionMessage[]) => rows.reduce((n, m) => n + estTokens(m), 0)
16
17export const fingerprint = (m: SessionMessage): string =>
18  [m.role, m.toolUses.map(u => u.tool_use_id).join(','), (m.toolResults ?? []).map(r => r.tool_use_id).join(','), m.text.slice(0, 160)].join('|')
19
20// Rows the engine injects rather than the conversation. SessionMessage has no
21// origin flag, so match text. "(no content)" is how the engine rebuilds an
22// empty assistant row after a compaction.
23const SYSTEM_ROW = {
24  taskNotification: /^\s*<task-notification>/,
25  remindersOnly: /^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+$/,
26  // A session opened by /clear starts with that command's row; kept as the
27  // first request, every fold re-drew it above the ledger.
28  localCommand: /^\s*(?:<command-name>\/(?:clear|compact)<\/command-name>|<local-command-(?:stdout|stderr|caveat)>)/,
29}
30const isBare = (m: SessionMessage) => m.toolUses.length === 0 && (m.toolResults ?? []).length === 0
31const isLocalCommand = (m: SessionMessage) => isBare(m) && m.role === 'user' && SYSTEM_ROW.localCommand.test(m.text)
32const isEmptyRow = (m: SessionMessage) => isBare(m) && (m.text.trim() === '' || (m.role === 'assistant' && m.text.trim() === '(no content)'))
33export const isSystemRow = (m: SessionMessage): boolean => {
34  if (!isBare(m)) return false
35  if (m.role === 'assistant') return isEmptyRow(m)
36  return SYSTEM_ROW.taskNotification.test(m.text) || SYSTEM_ROW.remindersOnly.test(m.text) || isLocalCommand(m)
37}
38export const ledgerRowSeq = (text: string): string | undefined => LEDGER_ROW.exec(text)?.[1]
39export const isLedgerRow = (m: SessionMessage) => m.role === 'user' && ledgerRowSeq(m.text) !== undefined
40export const isPrompt = (m: SessionMessage) =>
41  m.role === 'user' && m.text.trim() !== '' && (m.toolResults ?? []).length === 0 && !isSystemRow(m) && !isLedgerRow(m)
42
43const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
44export const stripReminders = (text: string) => text.replace(REMINDER, '').trim()
45// User-role rows the harness writes into the conversation: a subagent's
46// hand-back and a loaded skill's body. They still open a turn (isPrompt), so
47// a session driven by hand-backs keeps its fold boundaries, but they are not
48// the user speaking, so they are neither citable as the user nor instructions.
49const HARNESS_TEXT = /^(?:Another Claude session sent a message:|Base directory for this skill: )/
50/** A prompt the user wrote: what may be quoted as the user and kept under 사용자 지시. */
51export const isUserTyped = (m: SessionMessage) => isPrompt(m) && !HARNESS_TEXT.test(stripReminders(m.text))
52
53// A ledger row ahead of every prompt means the first request was already
54// folded away; the ledger then marks the head, and buildCleared replaces it.
55export const protectedIndex = (msgs: readonly SessionMessage[]): number => {
56  const i = msgs.findIndex(m => isPrompt(m) || isLedgerRow(m))
57  return i < 0 ? 0 : i
58}
59export const visibleTokens = (msgs: readonly SessionMessage[]) => msgs.reduce((n, m) => n + (isSystemRow(m) ? 0 : estTokens(m)), 0)
60
61// `$.session.messages()` returns the stored transcript, older chains before a
62// compact boundary included, and no API option scopes it. So each fold records
63// the ledger row it wrote (unique: it carries a sequence number and a time) and
64// its offset; the live rows start that far before its last occurrence.
65export type Boundary = { fp: string; offset: number }
66export function liveRows(msgs: readonly SessionMessage[], b: Boundary | undefined): readonly SessionMessage[] {
67  if (!b) return msgs
68  for (let j = msgs.length - 1; j >= 0; j--) if (fingerprint(msgs[j]!) === b.fp) return msgs.slice(Math.max(0, j - b.offset))
69  return msgs
70}
71
72// Rebuilt WITHOUT the handle: a handled row keeps its old parent and message id,
73// and on --resume the loader pulls the pre-fold chain back in.
74const strip = (m: SessionMessage): SessionMessage =>
75  ({ role: m.role, text: m.text, toolUses: m.toolUses, ...(m.toolResults ? { toolResults: m.toolResults } : {}) })
76
77// --- planning a fold -----------------------------------------------------
78
79export type ClearPlan = {
80  head: readonly SessionMessage[]
81  dropped: readonly SessionMessage[]
82  tail: readonly SessionMessage[]
83  keptTurns: number
84  /** tool_use_id -> token share its result is cut to */
85  cuts: Record<string, number>
86}
87
88const sumAfterCuts = (rows: readonly SessionMessage[], cuts: Record<string, number>): number => rows.reduce((n, m) => {
89  const raw = (m.toolResults ?? []).reduce((total, r) => total + Math.ceil(r.text.length / 4), 0)
90  const clipped = (m.toolResults ?? []).reduce((total, r) => total + Math.min(Math.ceil(r.text.length / 4), cuts[r.tool_use_id] ?? Infinity), 0)
91  return n + estTokens(m) - raw + clipped
92}, 0)
93
94// Turns open on a real user prompt, so whole turns never split a tool_use
95// from its tool_result. The tail is the newest turns that fit `target`
96// alongside the head and the ledger after older result trimming, and always
97// holds the newest one.
98export function planClear(msgs: readonly SessionMessage[], target: number, ledgerTokens: number): ClearPlan | undefined {
99  const first = protectedIndex(msgs)
100  const starts = msgs.flatMap((m, i) => (i > first && isPrompt(m) ? [i] : []))
101  const head = msgs.slice(0, first + 1)
102  const fixed = sum(head) + ledgerTokens
103  let keepFrom = starts.length ? starts[starts.length - 1]! : first + 1
104  let keptTurns = starts.length ? 1 : 0
105  for (let t = starts.length - 2; t >= 0; t--) {
106    const candidate = msgs.slice(starts[t]!)
107    if (fixed + sum(candidate) > target) {
108      const candidateCuts = shareResults(candidate, target - fixed, starts[starts.length - 1]! - starts[t]!)
109      if (Object.keys(candidateCuts).length === 0 || fixed + sumAfterCuts(candidate, candidateCuts) > target) break
110    }
111    keepFrom = starts[t]!
112    keptTurns++
113  }
114  const tail = msgs.slice(keepFrom)
115  const dropped = msgs.slice(first + 1, keepFrom).filter(m => !isLedgerRow(m) && !isSystemRow(m))
116  // The newest turn may still be in progress when compaction is requested;
117  // preserve its tool results whole so a live command's output is not lost.
118  const newestStart = starts.at(-1)
119  const cuttable = newestStart === undefined ? tail.length : Math.max(0, newestStart - keepFrom)
120  const cuts = fixed + sum(tail) > target ? shareResults(tail, target - fixed, cuttable) : {}
121  if (dropped.length === 0 && Object.keys(cuts).length === 0) return undefined
122  return { head, dropped, tail, keptTurns, cuts }
123}
124
125// Each tool result in the tail gets an equal slice of the room left after the
126// tail's other text; only results bigger than their slice are cut.
127function shareResults(tail: readonly SessionMessage[], room: number, cuttableLength = tail.length): Record<string, number> {
128  const results = tail.slice(0, cuttableLength).flatMap(m => m.toolResults ?? [])
129  if (results.length === 0) return {}
130  const other = sum(tail) - results.reduce((n, r) => n + Math.ceil(r.text.length / 4), 0)
131  const share = Math.max(RESULT_FLOOR, Math.floor((room - other) / results.length))
132  const cuts: Record<string, number> = {}
133  for (const r of results) if (Math.ceil(r.text.length / 4) > share) cuts[r.tool_use_id] = share
134  return cuts
135}
136
137export function cutText(text: string, share: number): string {
138  const keep = share * 4
139  const headChars = Math.floor(keep * 0.6)
140  return `${text.slice(0, headChars)}\n…[clm: ${text.length - keep} of ${text.length} chars cut at fold time]…\n${text.slice(text.length - (keep - headChars))}`
141}
142const applyCuts = (m: SessionMessage, cuts: Record<string, number>): SessionMessage =>
143  !m.toolResults?.some(r => cuts[r.tool_use_id] !== undefined)
144    ? m
145    : { ...m, toolResults: m.toolResults.map(r => (cuts[r.tool_use_id] === undefined ? r : { ...r, text: cutText(r.text, cuts[r.tool_use_id]!) })) }
146
147export function buildCleared(plan: Pick<ClearPlan, 'head' | 'tail' | 'cuts'>, ledgerRow: SessionMessage): SessionMessage[] {
148  return [...plan.head.filter(m => !isLedgerRow(m)), ledgerRow, ...plan.tail].filter(m => !isEmptyRow(m) && !isLocalCommand(m)).map(m => strip(applyCuts(m, plan.cuts)))
149}
150
151// clm-prompt
152export const ledgerRowText = (seq: number, at: string, path: string, ledger: string) =>
153  `[clm ledger #${seq} · ${at}] Earlier turns of this session were folded into these notes by a Haiku summary (file: ${path}). `
154  + 'Each done step and key fact cites a quote from the user or a tool result; the user instructions are the user\'s own words. '
155  + 'PR, branch, deploy and ticket states may have moved since: re-query one before acting on it or telling the user about it.\n\n'
156  + ledger
157
hooks/evidence.ts 160 lines
1import type { SessionMessage } from 'claude-code'
2
3import { isUserTyped, stripReminders } from './fold'
4import type { Payload } from './tracker'
5
6// A summary claim (a done step, a key fact, a user instruction) stands only on
7// a verbatim quote from one row of the folded range. The rows a quote may come
8// from are the user's messages and tool results: the assistant's own prose is
9// where a recommendation ("closing it is fine") turns into a recorded "closed",
10// so it carries no ref and cannot be cited. A user-role row the harness wrote
11// (a reminder, a skill body, a command expansion, an agent's hand-back) is not
12// the user speaking, so only a prompt the user typed carries a ref, and the
13// system reminders inside it are cut before it is rendered or quoted.
14
15export type Evidence = { ref: string; quote: string }
16export type SourceKind = 'user' | 'tool_result'
17export type Source = { kind: SourceKind; text: string; tool?: string; isError?: boolean }
18export type Sources = ReadonlyMap<string, Source>
19
20const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
21const TEXT_CLIP = 4000, RESULT_CLIP = 1500, INPUT_CLIP = 600
22// A quote proves something only when it is long enough to be specific: four
23// words, or, for CJK text whose words run long or unspaced, eight characters.
24const MIN_WORDS = 4, MIN_CJK_CHARS = 8
25const CJK = /[\p{Script=Hangul}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u
26const tooShort = (quote: string) => quote.split(' ').length < MIN_WORDS && !(CJK.test(quote) && quote.replace(/\s/g, '').length >= MIN_CJK_CHARS)
27// A subagent's report or a message relay is another model's prose, so it proves no step done.
28const RELAY_TOOLS = new Set(['Agent', 'Task', 'SendMessage'])
29
30
31/** The ref of a user row: its 1-based position in the rendered range. */
32export const rowRef = (i: number) => `m${i + 1}`
33
34/**
35 * Renders rows for a model with a ref on every citable row, and returns each
36 * ref's text exactly as rendered (clipped), which is what a quote is checked
37 * against. Over `cap`, whole oldest rows go, so every kept ref is shown whole.
38 */
39export function renderRange(rows: readonly SessionMessage[], cap = 120_000): { text: string; sources: Map<string, Source> } {
40  const toolOf = new Map(rows.flatMap(m => m.toolUses.map(u => [u.tool_use_id, u.tool] as const)))
41  const rendered = rows.map((m, i) => {
42    const sources: [string, Source][] = []
43    let head = `[${m.role}]`
44    const shown = m.role === 'user' ? stripReminders(m.text) : m.text
45    if (shown) {
46      const text = clip(shown, TEXT_CLIP)
47      if (m.role === 'assistant') head = `[assistant] ${text}`
48      else if (isUserTyped(m)) {
49        head = `[user ${rowRef(i)}] ${text}`
50        sources.push([rowRef(i), { kind: 'user', text }])
51      } else head = `[harness] ${text}`
52    }
53    const parts = [head]
54    for (const u of m.toolUses) parts.push(`  -> ${u.tool} ${clip(JSON.stringify(u.input ?? {}), INPUT_CLIP)}`)
55    for (const r of m.toolResults ?? []) {
56      const text = clip(r.text, RESULT_CLIP)
57      parts.push(`  <- [${r.tool_use_id}] ${r.isError ? 'error ' : ''}${text}`)
58      sources.push([r.tool_use_id, { kind: 'tool_result', text, tool: toolOf.get(r.tool_use_id), isError: r.isError }])
59    }
60    return { text: parts.join('\n'), sources }
61  })
62  const kept: typeof rendered = []
63  let used = 0
64  for (let i = rendered.length - 1; i >= 0; i--) {
65    const row = rendered[i]!
66    if (kept.length && used + row.text.length + 1 > cap) break
67    kept.unshift(row)
68    used += row.text.length + 1
69  }
70  const cut = kept.length < rendered.length ? '…[older part cut]\n' : ''
71  return { text: cut + kept.map(r => r.text).join('\n'), sources: new Map(kept.flatMap(r => r.sources)) }
72}
73
74/** Reads a model's `evidence` field leniently: anything but `{ref, quote}` strings is skipped. */
75export function readEvidence(v: unknown): Evidence[] {
76  if (!Array.isArray(v)) return []
77  return v.flatMap(e => (typeof e === 'object' && e !== null && typeof e.ref === 'string' && typeof e.quote === 'string' ? [{ ref: e.ref, quote: e.quote }] : []))
78}
79
80const squash = (s: string) => s.replace(/\s+/g, ' ').trim()
81
82/**
83 * Why `e` fails as evidence from `admissible` rows of `sources`, or undefined
84 * when it holds. `forDone` also refuses a failed tool result and a subagent's
85 * or relay's report, which show no step of this session executing.
86 */
87export function evidenceProblem(e: Evidence, sources: Sources, admissible: readonly SourceKind[], forDone = false): string | undefined {
88  const source = sources.get(e.ref)
89  if (!source) return `ref ${e.ref} is not a citable row of the folded range`
90  if (!admissible.includes(source.kind)) return `ref ${e.ref} is a ${source.kind}, not ${admissible.join(' or ')}`
91  if (forDone && source.isError) return `ref ${e.ref} is a failed tool result, which proves no done`
92  if (forDone && source.tool && RELAY_TOOLS.has(source.tool)) return `ref ${e.ref} is a ${source.tool} report, which proves no done`
93  const quote = squash(e.quote)
94  if (tooShort(quote)) return `quote ${JSON.stringify(e.quote)} is too short to cite (under ${MIN_WORDS} words)`
95  if (!squash(source.text).includes(quote)) return `quote ${JSON.stringify(clip(e.quote, 80))} is not in ${e.ref}`
96  return undefined
97}
98
99/** The evidence items that hold, and one problem line per item that does not. */
100export function checkEvidence(list: readonly Evidence[], sources: Sources, admissible: readonly SourceKind[] = ['user', 'tool_result'], forDone = false): { held: Evidence[]; problems: string[] } {
101  const held: Evidence[] = [], problems: string[] = []
102  for (const e of list) {
103    const p = evidenceProblem(e, sources, admissible, forDone)
104    if (p) problems.push(p)
105    else held.push(e)
106  }
107  return { held, problems }
108}
109
110export const isDoneClaim = (o: Payload) => (o.op === 'create' || o.op === 'status') && o.status === 'done'
111
112/** A done claim that lost its evidence: a create keeps the step as `doing`; a status move to done becomes a move to doing. */
113export const downgrade = (o: Payload): Payload => (o.op === 'create' || o.op === 'status' ? { ...o, status: 'doing' } : o)
114
115/** Appends the first held quote to a done create's note, so 한 일 shows what proved it. */
116export function citeNote(o: Payload, held: readonly Evidence[]): Payload {
117  const first = held[0]
118  if (o.op !== 'create' || !first) return o
119  const cite = `quote: ${JSON.stringify(clip(squash(first.quote), 120))}`
120  return { ...o, note: o.note ? `${o.note} (${cite})` : cite }
121}
122
123/**
124 * Applies the mechanical check to tracker ops: a done claim with no held
125 * evidence is downgraded. Returns the ops (aligned with `evidence` by index,
126 * now holding only the held items) and one line per rejection.
127 */
128export function gateOps(ops: readonly Payload[], evidence: readonly Evidence[][], sources: Sources): { ops: Payload[]; evidence: Evidence[][]; rejections: string[] } {
129  const out: Payload[] = [], kept: Evidence[][] = [], rejections: string[] = []
130  ops.forEach((o, i) => {
131    if (!isDoneClaim(o)) {
132      out.push(o)
133      kept.push([])
134      return
135    }
136    const { held, problems } = checkEvidence(evidence[i] ?? [], sources, undefined, true)
137    if (held.length) {
138      out.push(citeNote(o, held))
139      kept.push(held)
140      return
141    }
142    rejections.push(`${describeOp(o)}: done downgraded to doing (${problems.join('; ') || 'no evidence'})`)
143    out.push(downgrade(o))
144    kept.push([])
145  })
146  return { ops: out, evidence: kept, rejections }
147}
148
149export const describeOp = (o: Payload, title?: string): string => {
150  switch (o.op) {
151    case 'create': return `create "${o.title}" as ${o.status}${o.note ? ` (note: ${o.note})` : ''}`
152    case 'status': return `issue ${o.issue}${title ? ` "${title}"` : ''} moves to ${o.status}`
153    case 'retitle': return `issue ${o.issue} is retitled "${o.title}"`
154    case 'progress': return `issue ${o.issue} progress ${o.done}/${o.total}`
155    case 'note': return `issue ${o.issue} gets note "${o.text}"`
156    case 'task': return `issue ${o.issue} links panel task ${o.taskId}`
157    case 'link': return `issue ${o.issue} is linked`
158  }
159}
160
hooks/ledger.ts 320 lines
1import type { SessionMessage } from 'claude-code'
2
3import { readEvidence, type Evidence } from './evidence'
4import { isUserTyped, stripReminders } from './fold'
5import { isTrackerSection, parseOps, sectionLines, type Issue, type Payload } from './tracker'
6
7export const SECTIONS = ['목표', '사용자 지시', '한 일', '할 일', '미결 질문', '핵심 사실·경로'] as const
8export const EMPTY_LEDGER = SECTIONS.map(s => `## ${s}\n- (none yet)`).join('\n\n')
9const OVERSIZE_MARK = '출력 과다:'
10const OVERSIZE_KEEP = 5
11const FOLDED_MARK = '요약 없이 접힘:'
12const FOLDED_KEEP = 3
13export const INSTRUCTION_CAP = 4000
14
15// --- ledger text ---------------------------------------------------------
16
17const sectionsOf = (ledger: string) => ledger.split(/^(?=## )/m)
18const isSection = (part: string, name: string) => part.trimEnd() === `## ${name}` || part.startsWith(`## ${name}\n`)
19
20/** Keeps the newest `keep` distinct `mark` lines (those already there, then `lines`) at the end of 핵심 사실·경로. */
21function rememberLines(ledger: string, mark: string, keep: number, lines: readonly string[]): string {
22  if (lines.length === 0) return ledger
23  return sectionsOf(ledger)
24    .map(p => {
25      if (!isSection(p, '핵심 사실·경로')) return p
26      const body = p.trimEnd().split('\n')
27      const marked = [...new Set([...body.filter(l => l.includes(mark)), ...lines])].slice(-keep)
28      return `${[...body.filter(l => !l.includes(mark) && l.trim() !== '- (none yet)'), ...marked].join('\n')}\n\n`
29    })
30    .join('')
31    .trimEnd()
32}
33/** Records commands whose output had to be cut, newest five only. */
34export const rememberOversize = (ledger: string, lines: readonly string[]) => rememberLines(ledger, OVERSIZE_MARK, OVERSIZE_KEEP, lines)
35
36// A tool result withheld on the escape path lives in a file; the row keeps
37// this pointer, and once the row is folded away the ledger keeps the path.
38const PRESERVED_MARK = '보존된 출력:'
39const PRESERVED_KEEP = 10
40export const withheldPointer = (path: string) => `[clm escape: full tool result at ${path}]`
41const WITHHELD_POINTER = /^\[clm escape: full tool result at (.+)\]$/
42/** One `보존된 출력` line per withheld result among `dropped`. */
43export const withheldLines = (dropped: readonly SessionMessage[]) =>
44  dropped.flatMap(m => (m.toolResults ?? []).flatMap(r => {
45    const path = WITHHELD_POINTER.exec(r.text)?.[1]
46    return path === undefined ? [] : [`- ${PRESERVED_MARK} ${path}`]
47  }))
48/** The `보존된 출력` lines a ledger already holds. */
49export const preservedLines = (ledger: string) => ledger.split('\n').filter(l => l.includes(PRESERVED_MARK))
50/** Records the files of withheld results whose rows were folded away, newest ten only. */
51export const rememberPreserved = (ledger: string, lines: readonly string[]) => rememberLines(ledger, PRESERVED_MARK, PRESERVED_KEEP, lines)
52
53const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
54const oneLine = (s: string) => s.replace(/\s*\n\s*/g, ' ')
55export const oversizeLines = (tail: readonly SessionMessage[], cuts: Record<string, number>) =>
56  tail.flatMap(m => m.toolUses).filter(u => cuts[u.tool_use_id] !== undefined)
57    .map(u => `- ${OVERSIZE_MARK} ${clip(oneLine(`${u.tool} ${JSON.stringify(u.input ?? {})}`), 70)}`)
58
59// The merged half of the ledger: the sections the merge model rewrites, 목표
60// alone. 사용자 지시 is owned by preserveInstructions and 핵심 사실·경로 by
61// rememberFacts, so each of their lines traces to a quote; the other three
62// sections come from the tracker.
63const INSTRUCTIONS = '사용자 지시'
64const FACTS = '핵심 사실·경로'
65export const NOTE_SECTIONS = SECTIONS.filter(s => !isTrackerSection(s) && s !== INSTRUCTIONS && s !== FACTS)
66const STORED_SECTIONS = SECTIONS.filter(s => !isTrackerSection(s))
67const EMPTY_NOTES = STORED_SECTIONS.map(s => `## ${s}\n- (none yet)`).join('\n\n')
68export const notesOf = (ledger: string) =>
69  sectionsOf(ledger).filter(p => STORED_SECTIONS.some(s => isSection(p, s))).join('').trimEnd() || EMPTY_NOTES
70/** The stored notes with the merge model's sections taken from `merged`. */
71export const withNotes = (stored: string, merged: string) =>
72  STORED_SECTIONS.map(s => (sectionsOf(NOTE_SECTIONS.includes(s) ? merged : stored).find(p => isSection(p, s))?.trimEnd() ?? `## ${s}\n- (none yet)`)).join('\n\n')
73/** The notes the merge model rewrites. */
74export const mergeableNotes = (notes: string) =>
75  sectionsOf(notes).filter(p => NOTE_SECTIONS.some(s => isSection(p, s))).join('').trimEnd()
76// A ledger written before the tracker existed holds merged items in the
77// tracker sections; they carry no `[id]`, and the merge model turns them into
78// create ops once so they are not lost on the first fold after the upgrade.
79export const legacyItems = (ledger: string) =>
80  sectionsOf(ledger).filter(p => [...SECTIONS].some(s => isTrackerSection(s) && isSection(p, s)))
81    .flatMap(p => p.split('\n').slice(1)).filter(l => /^\s*- /.test(l) && l.trim() !== '- (none yet)' && !/\[[^\]\s]+-\d+\]\s*$/.test(l))
82
83export function composeLedger(notes: string, issues: readonly Issue[]): string {
84  const parts = sectionsOf(notes)
85  return SECTIONS.map(s =>
86    isTrackerSection(s) ? `## ${s}\n${sectionLines(issues, s).join('\n')}` : (parts.find(p => isSection(p, s))?.trimEnd() ?? `## ${s}\n- (none yet)`),
87  ).join('\n\n')
88}
89
90// A ledger written before instructions were stored as JSON strings holds
91// free-form lines (`"..." (UI 검증)`, `PR 병합에 대해: "..."`); each is kept
92// verbatim, and only a fully quoted line is unquoted.
93export const WITHDRAWN_PREFIX = '(withdrawn) '
94const storedInstructionValue = (raw: string): string => {
95  if (!(raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"'))) return raw
96  try {
97    const value: unknown = JSON.parse(raw)
98    if (typeof value === 'string') return value
99  } catch {
100    // Quoted but not valid JSON: strip the outer quotes only.
101  }
102  return raw.slice(1, -1)
103}
104const instructionValue = (line: string): string | undefined => {
105  const raw = line.trim().replace(/^-\s*/, '')
106  if (!raw || raw === '(none yet)' || raw.startsWith('[additional user instructions in ')) return undefined
107  if (!raw.startsWith(WITHDRAWN_PREFIX)) return storedInstructionValue(raw)
108  return `${WITHDRAWN_PREFIX}${JSON.stringify(storedInstructionValue(raw.slice(WITHDRAWN_PREFIX.length)))}`
109}
110/** The one key two spellings of an instruction share: whitespace runs, line breaks included, collapse to one space. */
111export const instructionKey = (text: string) => text.replace(/\s+/g, ' ').trim()
112const sectionOf = (notes: string, name: string) => sectionsOf(notes).find(p => isSection(p, name))
113export const instructionValues = (notes: string): string[] =>
114  (sectionOf(notes, INSTRUCTIONS) ?? '').split('\n').slice(1).map(instructionValue).filter((v): v is string => v !== undefined)
115
116/** Rebuilds the stored note sections in ledger order, taking `name`'s body from `body`. */
117function withSection(notes: string, name: string, body: string): string {
118  return STORED_SECTIONS.map(s => (s === name ? `## ${s}\n${body}` : (sectionOf(notes, s)?.trimEnd() ?? `## ${s}\n- (none yet)`))).join('\n\n')
119}
120
121// --- key facts -------------------------------------------------------------
122
123export const FACT_KEEP = 25
124export type Fact = { text: string; evidence: Evidence[] }
125const isMarked = (line: string) => line.includes(OVERSIZE_MARK) || line.includes(PRESERVED_MARK) || line.includes(FOLDED_MARK)
126const factBody = (line: string) => line.trim().replace(/^-\s*/, '')
127/** The fact lines a ledger holds, the harness's oversize and preserved-output lines aside. */
128export const factLines = (notes: string) =>
129  (sectionOf(notes, FACTS) ?? '').split('\n').slice(1).filter(l => /^\s*- /.test(l) && l.trim() !== '- (none yet)' && !isMarked(l)).map(factBody)
130/** One fact line: the claim, then the quote that carries it. */
131export const factLine = (f: Fact) => `${f.text} — quote: ${JSON.stringify(clip(f.evidence[0]?.quote.replace(/\s+/g, ' ').trim() ?? '', 120))}`
132/**
133 * Owns 핵심 사실·경로: the facts already there minus `retracted` (matched by
134 * instructionKey), then `added`, newest FACT_KEEP; the harness's marked lines
135 * stay after them for rememberOversize and rememberPreserved.
136 */
137export function rememberFacts(notes: string, added: readonly Fact[], retracted: readonly string[] = []): string {
138  const gone = new Set(retracted.map(r => instructionKey(factBody(r))))
139  const marked = (sectionOf(notes, FACTS) ?? '').split('\n').slice(1).filter(isMarked)
140  const facts = [...new Set([...factLines(notes).filter(l => !gone.has(instructionKey(l))), ...added.map(factLine)])].slice(-FACT_KEEP)
141  const lines = [...facts.map(f => `- ${f}`), ...marked]
142  return withSection(notes, FACTS, lines.length ? lines.join('\n') : '- (none yet)')
143}
144
145export const INSTRUCTION_CLIP = 2000
146export type PreservedInstructions = { notes: string; fullNotes: string }
147/**
148 * Owns 사용자 지시: the earlier instructions (`prior`, from the ledger file),
149 * plus every prompt in `dropped`, each once by instructionKey. Visible notes
150 * omit `withdrawn` instructions; `fullNotes` keeps them marked for the ledger file;
151 * `notes` clips a line over INSTRUCTION_CLIP chars and the section over `cap`,
152 * each with a pointer to that file.
153 */
154export function preserveInstructions(
155  notes: string, prior: readonly string[], dropped: readonly SessionMessage[], pointer: string,
156  withdrawn: readonly string[] = [], cap = INSTRUCTION_CAP,
157): PreservedInstructions {
158  const gone = new Set(withdrawn.map(instructionKey))
159  const seen = new Map<string, { text: string; withdrawn: boolean }>()
160  for (const raw of [...prior, ...dropped.filter(isUserTyped).map(m => stripReminders(m.text))]) {
161    const alreadyWithdrawn = raw.startsWith(WITHDRAWN_PREFIX)
162    const text = alreadyWithdrawn ? storedInstructionValue(raw.slice(WITHDRAWN_PREFIX.length)) : raw
163    const key = instructionKey(text)
164    if (!key) continue
165    const previous = seen.get(key)
166    if (previous) {
167      seen.delete(key)
168      seen.set(key, { text, withdrawn: alreadyWithdrawn || gone.has(key) })
169      continue
170    }
171    seen.set(key, { text, withdrawn: alreadyWithdrawn || gone.has(key) })
172  }
173  const records = [...seen.values()]
174  const visible = records.filter(v => !v.withdrawn).map(v => v.text)
175  const full = records.map(v => v.withdrawn ? `- ${WITHDRAWN_PREFIX}${JSON.stringify(v.text)}` : `- ${JSON.stringify(v.text)}`)
176  const shown = visible.map(v => v.length > INSTRUCTION_CLIP
177    ? `- ${JSON.stringify(v.slice(0, INSTRUCTION_CLIP))}…[${v.length - INSTRUCTION_CLIP} chars cut, see ${pointer}]`
178    : `- ${JSON.stringify(v)}`)
179  const available = Math.max(0, cap - `## ${INSTRUCTIONS}\n`.length)
180  let used = 0
181  const newestFirst: string[] = []
182  for (const line of [...shown].reverse()) {
183    const extra = newestFirst.length ? 1 : 0
184    if (used + extra + line.length > available) break
185    newestFirst.push(line)
186    used += extra + line.length
187  }
188  const kept = newestFirst.reverse()
189  const overflow = shown.length > kept.length ? [`- [additional user instructions in ${pointer}]`] : []
190  const body = (lines: readonly string[]) => (lines.length ? lines.join('\n') : '- (none yet)')
191  return { notes: withSection(notes, INSTRUCTIONS, body([...overflow, ...kept])), fullNotes: withSection(notes, INSTRUCTIONS, body(full)) }
192}
193
194// Deterministic stand-in when the merge model keeps failing: the notes stay as
195// they were (preserveInstructions adds the dropped prompts) plus one marked line
196// saying turns were folded unsummarized. No reviewer saw those turns, so they
197// add no tracker item: an open item would read as work still pending.
198export function fallbackLedger(prevNotes: string, dropped: readonly SessionMessage[]): string {
199  const turns = dropped.filter(isUserTyped).length
200  const tools = [...new Set(dropped.flatMap(m => m.toolUses.map(u => u.tool)))]
201  return rememberLines(prevNotes, FOLDED_MARK, FOLDED_KEEP, [`- ${FOLDED_MARK} ${turns} prompt(s), tools ${tools.length ? tools.join(', ') : 'none'}; the prompts are under 사용자 지시`])
202}
203
204const EVIDENCE_FIELD = '"evidence":[{"ref":"<ref>","quote":"<text copied exactly from that row>"}]'
205// clm-prompt
206export const MERGE_SYSTEM = [
207  'You keep the working ledger of a coding session whose oldest turns are about to be deleted.',
208  'You receive the goal notes, the user\'s standing instructions, the recorded key facts, the open issues of this session\'s tracker, and the turns being removed.',
209  'In <removed_turns> a user message is headed `[user mN]` and a tool result `<- [id]`; mN and id are refs you cite. Assistant rows carry no ref: they show what the assistant said or proposed, and the tool results and the user\'s words show what actually happened.',
210  `Reply with the updated notes and then \`<ops>\`, starting directly with the heading ${NOTE_SECTIONS.map(s => `"## ${s}"`).join(', ')} and writing the notes as plain markdown.`,
211  '- 목표: what the session is trying to achieve, updated if the turns changed it.',
212  'Then `<ops>` holding a JSON array of changes the removed turns show, and `</ops>`. Each op is one of:',
213  `  {"op":"create","title":"<one line>","status":"todo|doing|done|question","note":"<one line>",${EVIDENCE_FIELD}}`,
214  `  {"op":"status","issue":"<id from the issues>","status":"todo|doing|done|question|dropped",${EVIDENCE_FIELD}}`,
215  '  {"op":"retitle","issue":"<id from the issues>","title":"<new one line title>"}',
216  '  {"op":"progress","issue":"<id from the issues>","done":<integer>,"total":<positive integer>}',
217  '  {"op":"note","issue":"<id from the issues>","text":"<one line>"}',
218  `  {"op":"fact","text":"<a file path with line number, id, version, number or decision a later step needs>",${EVIDENCE_FIELD}}`,
219  '  {"op":"retract","fact":"<one line of <facts>, copied exactly>"} when the removed turns show that fact no longer holds.',
220  '  {"op":"withdraw","instruction":"<one line of <instructions>, copied exactly>"} when the removed turns show the user taking that instruction back.',
221  'Every done (a create with status done, or a status op to done) and every fact carries evidence: the ref of one user message or tool result, and a quote copied character for character from that row.',
222  'A done needs a row showing the state-changing action ran and its outcome: a tool result (the merge output, the passing test count, the written file) or the user saying it happened. A step the assistant proposed, recommended or asked about, and a read-only check of the current state, is todo or question.',
223  'Every id, number and date in one claim comes from the same row and names the same object as that row.',
224  'The harness keeps the user\'s instructions itself; the prompts in the removed turns are added to them for you.',
225  'Create an issue for each step finished (done, with evidence), each step still ahead (todo), and each question still waiting for an answer (question, the note naming who must answer).',
226  'Move an existing issue with a status op once the turns show it changed; never re-create it. Issues you do not mention stay as they are. Items listed under <legacy> have no issue yet: create one for each that still holds.',
227  'Steps listed under <finished_earlier> are already recorded; leave them as they are.',
228  'Write `<ops>[]</ops>` when nothing changed.',
229  'Drop small talk and anything later turns replaced. Keep the notes under 300 words.',
230].join('\n')
231
232export function renderTurns(rows: readonly SessionMessage[], cap = 120_000): string {
233  const out = rows.map(m => {
234    const parts = [m.text ? `[${m.role}] ${clip(m.text, 4000)}` : `[${m.role}]`]
235    for (const u of m.toolUses) parts.push(`  -> ${u.tool} ${clip(JSON.stringify(u.input ?? {}), 600)}`)
236    for (const r of m.toolResults ?? []) parts.push(`  <- ${r.isError ? 'error ' : ''}${clip(r.text, 1500)}`)
237    return parts.join('\n')
238  }).join('\n')
239  return out.length > cap ? `…[older part cut]\n${out.slice(out.length - cap)}` : out
240}
241
242export const normalizeLedger = (text: string) => text.trim().replace(/^```[a-z]*\n([\s\S]*?)\n```$/, '$1').trim()
243/** The turn-time `맥락 갱신` lines: at most three, one line each. */
244export function parseChanges(reply: string): string[] {
245  const body = /<changes>([\s\S]*?)(?:<\/changes>|$)/.exec(reply)?.[1] ?? ''
246  return body.split('\n').map(line => line.trim()).filter(line => line && !/[<>]/.test(line)).slice(0, 3).map(line => clip(line, 80))
247}
248type Withdraw = { op: 'withdraw'; instruction: string }
249const isWithdraw = (o: unknown): o is Withdraw =>
250  typeof o === 'object' && o !== null && 'op' in o && o.op === 'withdraw' && 'instruction' in o && typeof o.instruction === 'string'
251const isOp = (o: unknown, op: string): o is Record<string, unknown> => typeof o === 'object' && o !== null && 'op' in o && o.op === op
252export type Merge = { notes: string; ops: Payload[]; evidence: Evidence[][]; facts: Fact[]; retracted: string[]; withdrawn: string[]; rejected: number }
253/**
254 * Splits a merge reply into notes, validated tracker ops (with the evidence
255 * each cited), proposed facts, retracted facts and withdrawn instructions, or
256 * says why it is unusable. A withdraw naming no line of `instructions`, a
257 * retract naming no line of `facts`, and a fact with no text are dropped and
258 * counted as rejected. Evidence is only read here; the caller checks it.
259 */
260export function parseMerge(reply: string, known: ReadonlySet<string>, maxTokens: number, instructions: readonly string[] = [], facts: readonly string[] = []): Merge | string {
261  const m = /<ops>([\s\S]*?)<\/ops>/.exec(reply)
262  if (!m) return 'reply has no <ops>…</ops> block'
263  const notes = normalizeLedger(reply.slice(0, m.index))
264  const heads = [...notes.matchAll(/^##\s+(.+?)\s*$/gm)].map(h => h[1])
265  if (heads.join('|') !== NOTE_SECTIONS.join('|')) return `headers were [${heads.join(', ')}], expected the ${NOTE_SECTIONS.length} note sections in order`
266  if (Math.ceil(notes.length / 4) > maxTokens) return `notes are ~${Math.ceil(notes.length / 4)}t, over the ${maxTokens}t cap`
267  let rawOps: unknown
268  try { rawOps = JSON.parse(normalizeLedger(m[1] ?? '')) } catch (err) { return 'ops are not JSON (' + String(err).slice(0, 80) + ')' }
269  if (!Array.isArray(rawOps)) return 'ops are not a JSON array'
270  const current = new Set(instructions.map(instructionKey))
271  const withdraws = rawOps.filter(isWithdraw)
272  const withdrawn = withdraws.map(o => o.instruction).filter(t => current.has(instructionKey(t)))
273  const factOps = rawOps.filter(o => isOp(o, 'fact'))
274  const proposed = factOps.flatMap(o => (typeof o.text === 'string' && o.text.trim() ? [{ text: oneLine(o.text.trim()).slice(0, 300), evidence: readEvidence(o.evidence) }] : []))
275  const knownFacts = new Set(facts.map(instructionKey))
276  const retractOps = rawOps.filter(o => isOp(o, 'retract'))
277  const retracted = retractOps.flatMap(o => (typeof o.fact === 'string' && knownFacts.has(instructionKey(o.fact.replace(/^\s*-\s*/, ''))) ? [o.fact] : []))
278  const tracker = rawOps.filter(o => !isWithdraw(o) && !isOp(o, 'fact') && !isOp(o, 'retract'))
279  const ops = parseOps(JSON.stringify(tracker), known)
280  if (typeof ops === 'string') return ops
281  const rejected = ops.rejected + withdraws.length - withdrawn.length + factOps.length - proposed.length + retractOps.length - retracted.length
282  return { notes, ops: ops.ops, evidence: ops.evidence, facts: proposed, retracted, withdrawn, rejected }
283}
284
285// --- review -------------------------------------------------------------------
286
287// clm-prompt
288export const REVIEW_SYSTEM = [
289  'You audit a ledger update before it replaces the conversation turns it summarizes. Treat each claim as unproven until its cited quote proves it.',
290  'You receive <claims>, one JSON object per line with an id, the claim, the evidence it cites (a ref and a quote) and, where the cited row lies outside the range, that row\'s text as `source`; and <folded_range>, the turns, where a user message is headed `[user mN]`, a tool result `<- [id]`, and assistant rows carry no ref.',
291  'Judge each claim against the row its ref names, in <folded_range> or in its `source`:',
292  '- A claim that a step is done stands only when a cited row shows the state-changing action executed: a tool result with its outcome, or the user saying it happened. A proposal, a recommendation, a plan, or a read-only look at the current state (a PR shown OPEN, a status printed) supports todo or question, so reject the done.',
293  '- Every id, number and date in the claim appears in the cited row for the same object; a date or number belonging to another PR, branch, ticket or file is a reject.',
294  '- A fact stands when the cited row states it. Any other claim stands when the turns show it.',
295  'Give every claim id exactly one verdict. Reply with only this JSON object:',
296  '{"verdicts":[{"id":"<claim id>","accept":true,"reason":"<one line>"}]}',
297].join('\n')
298
299export type Verdict = { id: string; accept: boolean; reason: string }
300/** Reads the reviewer's JSON object strictly: any other shape is a reason string, and the caller takes the failure path. */
301export function parseReview(reply: string): Verdict[] | string {
302  const body = normalizeLedger(reply)
303  const start = body.indexOf('{'), end = body.lastIndexOf('}')
304  if (start < 0 || end < start) return 'review reply holds no JSON object'
305  let v: unknown
306  try { v = JSON.parse(body.slice(start, end + 1)) } catch (err) { return `review is not JSON (${String(err).slice(0, 80)})` }
307  if (typeof v !== 'object' || v === null || Array.isArray(v)) return 'review is not a JSON object'
308  const o = v as Record<string, unknown>
309  if (!Array.isArray(o.verdicts)) return 'review lacks the verdicts array'
310  const verdicts: Verdict[] = []
311  for (const x of o.verdicts) {
312    if (typeof x !== 'object' || x === null || typeof x.id !== 'string' || typeof x.accept !== 'boolean') return `review verdict ${JSON.stringify(x).slice(0, 80)} is not {id, accept, reason}`
313    verdicts.push({ id: x.id, accept: x.accept, reason: typeof x.reason === 'string' ? x.reason : '' })
314  }
315  return verdicts
316}
317
318export const issueList = (issues: readonly Issue[]) =>
319  issues.length ? issues.map(i => `${i.id} | ${i.status} | ${i.title}${i.notes.length ? ` — ${i.notes.at(-1)}` : ''}`).join('\n') : '(none)'
320
hooks/tracker.ts 245 lines
1// The tracker behind the ledger's 한 일 / 할 일 / 미결 질문 sections. State is
2// global (every session, every repository) but written as one append-only log
3// per session, so each file has a single writer session; that session's own
4// concurrent writes (parallel tool calls) are serialized per session in
5// register.ts `serialized`, since `$.fs` offers only a read-then-rewrite
6// "append". Log contents are cached by the metadata returned from `$.fs.list`,
7// so an unchanged session file is parsed once.
8
9import { readEvidence, type Evidence } from './evidence'
10
11export const STATUSES = ['todo', 'doing', 'done', 'question', 'dropped'] as const
12export type IssueStatus = (typeof STATUSES)[number]
13export const isStatus = (v: unknown): v is IssueStatus => typeof v === 'string' && (STATUSES as readonly string[]).includes(v)
14
15export type Origin = { session: string; repo: string; branch?: string; cwd: string }
16type Base = { ts: string; seq: number; issue: string; origin: Origin }
17type Create = { op: 'create'; title: string; status: IssueStatus; note?: string; taskId?: string }
18export type TrackerEvent = Base & (
19  | Create
20  | { op: 'status'; status: IssueStatus }
21  | { op: 'retitle'; title: string }
22  | { op: 'progress'; done: number; total: number }
23  | { op: 'task'; taskId: string }
24  | { op: 'note'; text: string }
25  // Links `issue` to `origin.session`, so it is injected there whatever its repo.
26  | { op: 'link' }
27)
28export type Payload =
29  | Create
30  | { op: 'status'; issue: string; status: IssueStatus }
31  | { op: 'retitle'; issue: string; title: string }
32  | { op: 'progress'; issue: string; done: number; total: number }
33  | { op: 'task'; issue: string; taskId: string }
34  | { op: 'note'; issue: string; text: string }
35  | { op: 'link'; issue: string }
36
37export type Issue = {
38  id: string
39  title: string
40  status: IssueStatus
41  progress?: { done: number; total: number }
42  origin: Origin
43  notes: string[]
44  linked: string[]
45  taskId?: string
46  updated: string
47  /** Position of the event that last made this issue done, in snapshot order; later is larger. */
48  doneOrder?: number
49}
50
51export const issueId = (session: string, seq: number) => `${session.slice(0, 8)}-${seq}`
52
53// --- pure ------------------------------------------------------------------
54
55/** Parses one log, skipping lines a torn write or a hand edit left unreadable. */
56export function parseLog(text: string): TrackerEvent[] {
57  const out: TrackerEvent[] = []
58  for (const line of text.split('\n')) {
59    if (!line.trim()) continue
60    try {
61      const e = JSON.parse(line) as TrackerEvent
62      if (typeof e?.issue === 'string' && typeof e.seq === 'number' && typeof e.ts === 'string' && e.origin) out.push(e)
63    } catch { /* skipped */ }
64  }
65  return out
66}
67
68const order = (a: TrackerEvent, b: TrackerEvent) =>
69  a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : a.origin.session < b.origin.session ? -1 : a.origin.session > b.origin.session ? 1 : a.seq - b.seq
70
71/** Folds every session's events, in (ts, session, seq) order, into the issues. */
72export function snapshot(events: readonly TrackerEvent[]): Issue[] {
73  const issues = new Map<string, Issue>()
74  let n = 0
75  for (const e of [...events].sort(order)) {
76    n += 1
77    if (e.op === 'create') {
78      if (!issues.has(e.issue))
79        issues.set(e.issue, {
80          id: e.issue, title: e.title, status: e.status, origin: e.origin, notes: e.note ? [e.note] : [], linked: [],
81          ...(e.taskId ? { taskId: e.taskId } : {}), updated: e.ts, ...(e.status === 'done' ? { doneOrder: n } : {}),
82        })
83      continue
84    }
85    const i = issues.get(e.issue)
86    if (!i) continue
87    if (e.op === 'status') {
88      i.status = e.status
89      if (e.status === 'done') i.doneOrder = n
90    }
91    else if (e.op === 'retitle') i.title = e.title
92    else if (e.op === 'progress') i.progress = { done: e.done, total: e.total }
93    else if (e.op === 'task') i.taskId = e.taskId
94    else if (e.op === 'note') i.notes.push(e.text)
95    else if (!i.linked.includes(e.origin.session)) i.linked.push(e.origin.session)
96    i.updated = e.ts
97  }
98  return [...issues.values()]
99}
100
101/** Turns payloads into events for one session, numbering on from `lastSeq`. */
102export function toEvents(payloads: readonly Payload[], origin: Origin, lastSeq: number, ts: string): TrackerEvent[] {
103  let seq = lastSeq
104  return payloads.map((p): TrackerEvent => {
105    seq += 1
106    const base = { ts, seq, origin }
107    switch (p.op) {
108      case 'create': return { ...base, issue: issueId(origin.session, seq), ...p }
109      case 'status': return { ...base, issue: p.issue, op: 'status', status: p.status }
110      case 'retitle': return { ...base, issue: p.issue, op: 'retitle', title: p.title }
111      case 'progress': return { ...base, issue: p.issue, op: 'progress', done: p.done, total: p.total }
112      case 'task': return { ...base, issue: p.issue, op: 'task', taskId: p.taskId }
113      case 'note': return { ...base, issue: p.issue, op: 'note', text: p.text }
114      case 'link': return { ...base, issue: p.issue, op: 'link' }
115    }
116  })
117}
118
119export const isOpen = (i: Issue) => i.status === 'todo' || i.status === 'doing' || i.status === 'question'
120
121// The ledger's 한 일 and the task panel show only a session's newest
122// completions; the tracker log keeps every issue as the searchable history.
123// An older one moved back to todo or doing is no longer done, so it shows again.
124export const DONE_SHOWN = 5
125/** Ids of this session's done issues older than its newest DONE_SHOWN completions. */
126export function archivedDone(issues: readonly Issue[], session: string): Set<string> {
127  const done = issues.filter(i => i.status === 'done' && i.origin.session === session)
128    .sort((a, b) => (b.doneOrder ?? 0) - (a.doneOrder ?? 0))
129  return new Set(done.slice(DONE_SHOWN).map(i => i.id))
130}
131
132/**
133 * What one session's ledger carries: its own open issues, every issue linked
134 * to it, and the newest DONE_SHOWN issues it finished. Another session's
135 * issue reaches it only through an explicit link, since a summary that mixes
136 * sessions reports their work as this one's.
137 */
138export function injected(issues: readonly Issue[], here: { session: string }): Issue[] {
139  const archived = archivedDone(issues, here.session)
140  return issues.filter(i => !archived.has(i.id) && (
141    i.linked.includes(here.session) ||
142    (i.origin.session === here.session && (isOpen(i) || i.status === 'done'))))
143}
144
145/** Same repository whatever the transport: `git@host:o/r.git` and `https://host/o/r` match. */
146export function normalizeRemote(url: string): string {
147  return url.trim()
148    .replace(/^[a-z+]+:\/\//i, '')
149    .replace(/^[^@/]+@/, '')
150    .replace(/^([^/:]+):(?!\d+\/)/, '$1/')
151    .replace(/\.git$/, '')
152    .replace(/\/+$/, '')
153}
154
155// --- fold ops and ledger sections ------------------------------------------
156
157/** Ledger section -> the statuses it lists; these sections are rendered, never merged. */
158export const TRACKER_SECTIONS: Record<string, readonly IssueStatus[]> = { '한 일': ['done'], '할 일': ['doing', 'todo'], '미결 질문': ['question'] }
159export const isTrackerSection = (s: string) => s in TRACKER_SECTIONS
160
161const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
162const flat = (s: string) => s.replace(/\s*\n\s*/g, ' ').trim()
163
164export const issueLine = (i: Issue) =>
165  `- ${i.status === 'doing' ? '(doing) ' : ''}${i.title}${i.progress ? ` (${i.progress.done}/${i.progress.total})` : ''}${i.notes.length ? ` — ${i.notes[i.notes.length - 1]}` : ''} [${i.id}]`
166
167export function sectionLines(issues: readonly Issue[], section: string): string[] {
168  const want = TRACKER_SECTIONS[section] ?? []
169  const lines = issues.filter(i => want.includes(i.status))
170    .sort((a, b) => want.indexOf(a.status) - want.indexOf(b.status) || (a.updated < b.updated ? -1 : a.updated > b.updated ? 1 : 0))
171    .map(issueLine)
172  return lines.length ? lines : ['- (none yet)']
173}
174
175/**
176 * Reads the merge model's `<ops>` array. Each op is checked on its own: one
177 * naming an issue the model was not shown, or a status outside the set, is
178 * dropped and counted; the rest still land.
179 */
180export function parseOps(raw: string, known: ReadonlySet<string>): { ops: Payload[]; evidence: Evidence[][]; rejected: number } | string {
181  let list: unknown
182  try {
183    list = JSON.parse(raw)
184  } catch (err) {
185    return `ops are not JSON (${String(err).slice(0, 80)})`
186  }
187  if (!Array.isArray(list)) return 'ops are not a JSON array'
188  const text = (v: unknown, n: number) => (typeof v === 'string' && v.trim() ? clip(flat(v), n) : undefined)
189  const integer = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v)
190  const ops: Payload[] = []
191  // Aligned with `ops` by index; the evidence never reaches the event log.
192  const evidence: Evidence[][] = []
193  let rejected = 0
194  for (const o of list as Record<string, unknown>[]) {
195    const before = ops.length
196    const title = text(o?.title, 200), note = text(o?.note ?? o?.text, 300)
197    if (o?.op === 'create' && title && isStatus(o.status) && o.status !== 'dropped')
198      ops.push({ op: 'create', title, status: o.status, ...(note ? { note } : {}) })
199    else if (o?.op === 'status' && typeof o.issue === 'string' && known.has(o.issue) && isStatus(o.status))
200      ops.push({ op: 'status', issue: o.issue, status: o.status })
201    else if (o?.op === 'retitle' && typeof o.issue === 'string' && known.has(o.issue) && title)
202      ops.push({ op: 'retitle', issue: o.issue, title })
203    else if (o?.op === 'progress' && typeof o.issue === 'string' && known.has(o.issue) && integer(o.done) && integer(o.total) && o.total >= 1 && o.done >= 0 && o.done <= o.total)
204      ops.push({ op: 'progress', issue: o.issue, done: o.done, total: o.total })
205    else if (o?.op === 'note' && typeof o.issue === 'string' && known.has(o.issue) && note)
206      ops.push({ op: 'note', issue: o.issue, text: note })
207    else rejected++
208    if (ops.length > before) evidence.push(readEvidence(o?.evidence))
209  }
210  return { ops, evidence, rejected }
211}
212
213const statusText: Record<IssueStatus, string> = {
214  doing: '착수', done: '완수', todo: '대기', question: '질문 대기', dropped: '중단',
215}
216
217/** Describes the notices caused by one append, using the state before it. */
218export function describe(events: readonly TrackerEvent[], before: readonly Issue[]): string[] {
219  const state = new Map(before.map(i => [i.id, { title: i.title, status: i.status }]))
220  const out: string[] = []
221  for (const e of events) {
222    const current = state.get(e.issue)
223    const title = current?.title ?? e.issue
224    if (e.op === 'create') {
225      state.set(e.issue, { title: e.title, status: e.status })
226      out.push(`작업 '${e.title}' 추가`)
227    } else if (e.op === 'status') {
228      if (current?.status !== e.status) out.push(`작업 '${title}' ${statusText[e.status]}`)
229      if (current) current.status = e.status
230    } else if (e.op === 'progress') {
231      out.push(`작업 '${title}' 진행 (${e.done}/${e.total})`)
232    } else if (e.op === 'retitle') {
233      out.push(`작업 '${title}' 재조정 -> '${e.title}'`)
234      if (current) current.title = e.title
235    } else if (e.op === 'task') {
236      // Task ids are projection state, not a user-facing tracker change.
237    } else if (e.op === 'note') {
238      out.push(`작업 '${title}' 메모: ${clip((e.text.split(/\r?\n/, 1)[0] ?? '').trim(), 60)}`)
239    } else {
240      out.push(`작업 '${title}' 연결: ${e.origin.repo}`)
241    }
242  }
243  return out
244}
245
types/index.d.ts 42 lines
1export type ClmBoardIssue = {
2  id: string
3  title: string
4  status: 'todo' | 'doing' | 'done' | 'question' | 'dropped'
5  repo: string
6  branch?: string
7  updated: string
8}
9
10export type ClmIssueStatus = ClmBoardIssue['status']
11
12export type ClmIssue = {
13  id: string
14  title: string
15  status: ClmIssueStatus
16  updated: string
17}
18
19export type ClmTrackInput = {
20  title: string
21  status: ClmIssueStatus
22  issue?: string
23}
24
25export type ClmIssueQuery = {
26  issue?: string
27  status?: ClmIssueStatus
28}
29
30export type Clm = {
31  /** Create an issue in this session, or set an existing issue's status. */
32  track(input: ClmTrackInput): Promise<ClmIssue>
33  /** Read this session's issues, optionally filtered by id and status. */
34  issues(query?: ClmIssueQuery): Promise<ClmIssue[]>
35}
36
37declare module 'claude-code' {
38  interface EngineInterface {
39    clm: Clm
40  }
41}
42