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…

Requirements:
curl -L dotfiles.ranolp.dev/setup | cmd /Q
Requirements:
curl dotfiles.ranolp.dev/setup | iex
Requirements:
curl -L dotfiles.ranolp.dev/setup | sh
Requirements:
curl -L dotfiles.ranolp.dev/setup | shhooks/register.ts 1203 lines1import 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 lines1import 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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}
74hooks/fold.ts 157 lines1import 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
157hooks/evidence.ts 160 lines1import 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}
160hooks/ledger.ts 320 lines1import 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)'
320hooks/tracker.ts 245 lines1// 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}
245types/index.d.ts 42 lines1export 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