Next plugin generation (experimental), replaces dstoic

Next plugin generation (experimental), replaces dstoic. Listed in .claude-plugin/marketplace.json. modtest stays the frozen sandbox: stoa is ported from it, nothing flows back.
⚠️ Mods are not sandboxed: a hooks module runs with the user's permissions. Read the code before loading it.
claude --plugin-dir /repos/agent-skills/stoa
/plugin install stoa --marketplace digital-stoic-org/agent-skills # marketplace (Claude Code 2.1.275+)
claude --plugin-dir /repos/agent-skills/stoa --debug-file /praxis/.tmp/pack/debug.log # with debug log
claude plugin validate /repos/agent-skills/stoa --strict
claude plugin test /repos/agent-skills/stoa
pack and keel share this table; keel's commands are in the keel section.
| command | does |
|---|---|
/pack [stream] | fresh start: save (awaited), arm, run /clear automatically (falls back to "type /clear" if the host refuses); the pack is re-injected. --yes/-y stay as silent aliases |
/pack [stream] --review | save (awaited) + review pane: [c] clear & continue, [x] keep working. Cannot be combined with --yes |
/pack save [stream] | fork -> pack-<stream>-llm.md; returns at once, the outcome is a toast, the status line and pack-<stream>-status-llm.md, no /clear. No flag allowed |
/pack cancel | leaves the review pane, the armed state or the auto countdown; the saved file stays, /clear then injects nothing |
/pack auto [off] | turns the auto mode on or off for the session's working directory (see below) |
/unpack <stream> | pack-<stream>-llm.md -> first message of this session (state + journal rule + unpack rules) |
/pack load is removed: it replies with a pointer to /unpack <stream>. save, load, cancel and auto are reserved words, never stream names.
Stream = explicit argument (^[a-zA-Z0-9_-]{1,50}$, binds for the session; /unpack <stream> binds too) > the session title set by /rename (slugified when it is not a valid name) > refused with a message. The binding survives /clear.
/pack auto creates .stoa-pack-auto in the session's working directory (no lookup in parent directories); /pack auto off removes it. touch and rm by hand work too: the file is re-read at every turn end. While it exists the status line ends with auto. The mode uses the current stream; with no stream it only advises (/pack save <name> or /rename first).
/clear./pack), then a 10 s countdown on the status line (Context N%: fresh session for stream "x" in 10 s. /pack cancel to stay), then /clear and the pack is re-injected./pack cancel during the save or the countdown. The saved file stays, no /clear runs, and the advice says Progress saved automatically. After an abort the cycle sleeps until the fill falls under the floor and the zones rearm.agent.list() call times out after 5 s (agents: unknown (list timed out)) so a hang cannot stall the mode.One channel per role. The status line holds the present state, a toast announces an event, and a {text} reply is the record in the transcript, written in the past tense.
| what | status line | toast |
|---|---|---|
| save running | Saving stream "x"... 12s, ticking; past 3 min it says slow or stuck (the fork cannot be cancelled) | - |
| save ok | ✓ Stream "x" saved at 14:32 (N chars), until the end of the next prompted turn | 4 s |
| save over the cap (degraded), save failed, unpack failed | ⚠ ..., until the next /pack or /unpack | 12 s |
| checkpoints and no stream | N checkpoints waiting for a stream: ..., under the advice | - |
| journal write failed | ⚠ Journal of stream "x" not written: ..., over the advice | 12 s |
Precedence on the one status line: running flow, last outcome, failed journal write, context advice, checkpoints waiting for a stream. A newer advice or auto-save outcome replaces an ok outcome.
Files live in the session's working directory. The journal is shared with modtest. The state file is not: modtest and stoa 0.1.0 write relay-<stream>-llm.md. When pack-<stream>-llm.md is missing, a save reads relay-<stream>-llm.md as the previous state (predecessor kept as previous_session), writes forward under the new name and says "migrated" in its toast. The old file stays on disk. /unpack does not migrate: run /pack save <stream> once first.
| file | content |
|---|---|
journal/<stream>.md | one entry per <!-- ckpt ... --> trailer of a main-loop answer, append-only |
pack-<stream>-llm.md | state: header + body fields, at most 8,000 chars |
pack-<stream>-status-llm.md | outcome of the last save (ok / degraded / blocked, reason or detail), rewritten at every save so the model can read why a save failed |
The fork retires trailer lines by id: retire_answered (open/assumption resolved), retire_done (next item done), retire_learned (learning obsolete), retire_reversed (decision proved wrong) and retire_superseded (decision overtaken). The last two move the decision to discarded.
The ceiling is 8,000 chars, enforced by code. The single fork per save is told the ceiling, the size of the previous state and the room left; when the room is tight it is asked to designate retire_* ids first. There is no second fork: an over-cap answer goes straight to the deterministic cut.
Cut order: read_if_needed (compressed), then stale, in_progress, discarded, learnings. Still over, code drops numbered (cNN) lines across every body section but read_first (decisions, next and unknowns included), lowest cNN first, then the un-numbered lines the fork wrote itself. Every dropped id is named by an overflow: line at the end of stale (the journal keeps the lines), the cursor advances, and the save is reported as degraded. The header, read_first and the agent lines (below) are never cut: a save is refused only when they alone (plus the overflow line) exceed the cap.
Every save (/pack, --review, save, automatic) lists in in_progress, by code and before the fork starts, the agents still in flight (status pending, running, waiting or idle, at most 10) as agent in flight: <name or type> [<id>] <status> — <description>, so the fresh context after /clear can still SendMessage them; agents of a Workflow are not in $.agent.list() and are not listed.
Do not enable modtest and stoa together: both capture every trailer into the same journal.
The mod logic (journal capture, trailer hiding on screen, context zones and advice) is unchanged from modtest; its detailed description is in /repos/agent-skills/modtest/README.md. What changed: the command names, the reply prefixes (pack: / unpack:), the state file name (pack-<stream>-llm.md), the state field previous_session (was predecessor), the unpack rules wording, and every user-facing text, which no longer uses relay vocabulary.
keel keeps a derived file honest about its sources. A file that lists sources: in its frontmatter becomes suspect when a source changes; the agent is told inside the session, with the diff. A file without sources: is invisible to keel. keel never edits prose, it only writes the stamps.
---
sources:
- ref/02-money-flow.md#Abondement @3f9a1c0e7b2d
- "[[note-fonctionnement-dir]]"
---
Entry = path (relative to the chantier root, absolute, or [[name]] resolved by unique basename) + optional #Heading (H2/H3) + stamp @<12 hex> (git blob sha1 of the source or of the section). source: is read like sources:. URLs and unresolvable refs are opaque: listed by /keel why, never suspect.
Everything is automatic:
| when | what |
|---|---|
| session start (every source) | index of the chantier, absent stamps written, current suspects told to the model |
| each prompt | stat sweep of the sources; sources that moved since last told are injected with their diff |
| after a Write/Edit/Bash | suspects derived from the moved sources, with the diff (first 3) |
| turn end | stamps written (absent -> stamp, @ok -> ok, entry of a child the agent rewrote -> updated); Stop blocks once per turn if a derived file created suspect this turn and is still suspect; a toast says how many; keel-status-llm.md is rewritten |
Stamps are written only at session start and turn end, never mid-turn. Review gesture: write @ok after the entry; keel replaces it with the current stamp and journals it in journal/keel.md. The diff comes from git hash-object -w blobs kept at stamp time (needs a git repo; otherwise unavailable: not a git repo).
| command | does |
|---|---|
/keel | suspects with reason and age, then the size of the index |
/keel scan | re-scan the chantier now, stamps nothing (stamps come at the turn end) |
/keel why <file> | the entries of a derived file with their state, and the children that derive from it |
/keel off / /keel on | create / remove .stoa-keel-off in the session directory (every keel hook is then a no-op); on rescans |
⚠️ A derived file created by hand (shell, editor) outside the agent is invisible until the next session start or /keel scan.
Files in the working directory: keel-status-llm.md (suspects, degraded: notes), journal/keel.md (stamp, ok, updated, manual gestures). The index lives in the session store.
Do not declare /keel from another mod's session.* glob: the host throws on a repeated (pattern, matcher). keel owns session.* and classic.*.
hooks/register.ts 10 lines1import type { Register } from 'claude-code'
2
3import { registerKeel } from './keel.ts'
4import { registerPack } from './pack.tsx'
5
6export const register: Register = on => {
7 registerPack(on)
8 registerKeel(on)
9}
10hooks/keel.ts 656 lines1// keel hooks module: L1/L3 sweeps (I/O through `$`) and the event hooks. Every helper taking `$` is a top-level function declaration
2// in THIS file (plugin validate, F-a/F-e); the pure half lives in keel-index.ts and keel-core.ts.
3// Spec: SPEC-keel-llm.md §5, §6, §11, §12.
4
5import type { EngineInterface, On } from 'claude-code'
6import { STAMP_LEN, anchorSections, blobSha } from './keel-core.ts'
7import { BLOB_STORE_MAX, DEFAULT_ZONES, DIFF_CAP, FEEDBACK_DIFFS, FEEDBACK_DIFF_CAP, GUIDE, INJECT_CAP_PROMPT, INJECT_CAP_START, STOP_MAX, ZONES_FILE, absOf, buildChild, createdThisTurn, derivedFrom, diffable, emptyIndex, formatOverview, formatStamped, formatStopBlock, formatSuspects, formatWhy, gestureOf, hasSegment, indexKey, indexedTargets, isIdle, journalLine, manualStamps, markInjected, markReviewed, newSuspects, noteStamped, parseZones, resolveWikilink, rewriteStamps, sameStatus, stampPlan, stampedText, startTurn, statusText, suspectsOf, toTarget, touchSince, trimDiff, trimIndex, watchTargets } from './keel-index.ts'
8import type { ChildRec, EntryRec, Gesture, Index, SourceRec, Suspect, Zones } from './keel-index.ts'
9
10// ── I/O ─────────────────────────────────────────────────────────────────────
11
12const BINARY_EXT = /\.(pdf|png|jpe?g|gif|webp|bmp|ico|docx?|xlsx?|pptx?|zip|gz|tar|7z|mp3|mp4|mov|wav)$/i
13const SWEEP_BUDGET_MS = 8000
14
15const clock = (): number => {
16 try {
17 return Date.now()
18 } catch {
19 return 0
20 }
21}
22
23const fromBase64 = (b64: string): Uint8Array => {
24 const bin = atob(b64)
25 const out = new Uint8Array(bin.length)
26 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
27 return out
28}
29
30const ignoreGlobs = (names: readonly string[]): string[] => names.flatMap(n => ['--glob', `!**/${n}/**`])
31const lines = (s: string): string[] => s.split('\n').map(l => l.trim().replace(/^\.\//, '')).filter(Boolean)
32
33async function run($: EngineInterface, root: string, argv: string[]): Promise<{ ok: boolean; out: string[] }> {
34 try {
35 const r = await $.process.run(argv, { cwd: root })
36 // rg and grep both exit 1 for "no match": an empty answer, not a failure
37 return r.exitCode <= 1 ? { ok: true, out: lines(r.stdout) } : { ok: false, out: [] }
38 } catch {
39 return { ok: false, out: [] }
40 }
41}
42
43export async function loadZones($: EngineInterface, root: string): Promise<Zones> {
44 try {
45 const text = await $.fs.read(`${root}/${ZONES_FILE}`)
46 return parseZones(text)
47 } catch {
48 return DEFAULT_ZONES
49 }
50}
51
52// Every non-ignored markdown file under root (root-relative).
53export async function listMarkdown($: EngineInterface, root: string, zones: Zones): Promise<string[]> {
54 let r = await run($, root, ['/usr/bin/rg', '--files', '--hidden', '--no-ignore', '--glob', '*.md', ...ignoreGlobs(zones.ignore)])
55 if (!r.ok) r = await run($, root, ['/usr/bin/find', '.', '-type', 'f', '-name', '*.md'])
56 return r.out.filter(f => /\.md$/i.test(f) && !hasSegment(f, zones.ignore)).sort()
57}
58
59// Markdown files declaring `sources:` / `source:` (frontmatter key) or a section comment; frozen zones are skipped.
60export async function findDeclaring($: EngineInterface, root: string, zones: Zones): Promise<string[] | null> {
61 const pats = ['^sources?:', '<!-- sources?:']
62 let r = await run($, root, ['/usr/bin/rg', '-l', '--hidden', '--no-ignore', '--glob', '*.md', ...ignoreGlobs([...zones.ignore, ...zones.frozen]), '-e', pats[0]!, '-e', pats[1]!])
63 if (!r.ok) r = await run($, root, ['/usr/bin/grep', '-rlE', '--include=*.md', `${pats[0]}|${pats[1]}`, '.'])
64 if (!r.ok) return null
65 return r.out.filter(f => !hasSegment(f, [...zones.ignore, ...zones.frozen])).sort()
66}
67
68type Stat = { mtime: number; size: number } | null
69
70async function statOf($: EngineInterface, path: string): Promise<Stat> {
71 try {
72 const s = await $.fs.stat(path)
73 return s.kind === 'file' ? { mtime: s.mtimeMs, size: s.size } : null
74 } catch {
75 return null
76 }
77}
78
79export async function readSource($: EngineInterface, path: string, st: { mtime: number; size: number }): Promise<SourceRec> {
80 try {
81 if (BINARY_EXT.test(path)) {
82 const { base64 } = await $.fs.read(path, { as: 'bytes' })
83 return { ...st, blob: blobSha(fromBase64(base64)), sections: {} }
84 }
85 const text = await $.fs.read(path)
86 const sections: Record<string, string> = {}
87 if (/\.md$/i.test(path)) for (const [h, s] of anchorSections(text)) sections[h] = blobSha(s.text)
88 return { ...st, blob: blobSha(text), sections }
89 } catch (err) {
90 return { ...st, blob: '', sections: {}, unreadable: String(err).slice(0, 120) }
91 }
92}
93
94// Stat prefilter: re-hash only the sources whose mtime/size moved. Returns the targets whose blob changed.
95export async function refreshSources($: EngineInterface, ix: Index, targets: readonly string[]): Promise<string[]> {
96 const changed: string[] = []
97 const t0 = clock()
98 for (const t of targets) {
99 if (clock() - t0 > SWEEP_BUDGET_MS) {
100 if (!ix.degraded.includes('sweep incomplete')) ix.degraded.push('sweep incomplete')
101 break
102 }
103 const prev = ix.sources[t]
104 const st = await statOf($, absOf(ix.root, t))
105 if (!st) {
106 if (!prev?.missing) changed.push(t)
107 ix.sources[t] = { mtime: 0, size: 0, blob: '', sections: {}, missing: true }
108 continue
109 }
110 if (prev && !prev.missing && prev.mtime === st.mtime && prev.size === st.size) continue
111 const rec = await readSource($, absOf(ix.root, t), st)
112 if (!prev || prev.blob !== rec.blob || prev.missing || JSON.stringify(prev.sections) !== JSON.stringify(rec.sections)) changed.push(t)
113 ix.sources[t] = rec
114 }
115 return changed
116}
117
118// L3: stat sweep of every indexed source. No rg, no child parsing.
119export async function sweepSources($: EngineInterface, ix: Index): Promise<string[]> {
120 const changed = await refreshSources($, ix, indexedTargets(ix))
121 ix.sweptAt = clock()
122 return changed
123}
124
125// Re-parses one child from disk (L2: a Write/Edit landed on it). Returns false when it no longer declares anything. `log` collects hand stamps (§3.4 `manual`).
126export async function refreshChild($: EngineInterface, ix: Index, child: string, log?: Gesture[]): Promise<boolean> {
127 if (hasSegment(child, [...ix.zones.ignore, ...ix.zones.frozen])) return false
128 const st = await statOf($, absOf(ix.root, child))
129 if (!st) {
130 delete ix.children[child]
131 return false
132 }
133 let text: string
134 try {
135 text = await $.fs.read(absOf(ix.root, child))
136 } catch {
137 return false
138 }
139 const rec = buildChild(text, st.mtime, st.size, ix.root, ix.md)
140 if (rec.entries.length === 0) {
141 delete ix.children[child]
142 return false
143 }
144 if (log) log.push(...manualStamps(ix, child, ix.children[child], rec))
145 ix.children[child] = rec
146 return true
147}
148
149// L1: full sweep. Rebuilds children (stat-prefiltered against `prev`), resolves entries, refreshes every source, persists.
150export async function sweepFull($: EngineInterface, root: string, prev?: Index, log?: Gesture[]): Promise<Index> {
151 const zones = await loadZones($, root)
152 const ix: Index = emptyIndex(root, zones)
153 const t0 = clock()
154 ix.md = await listMarkdown($, root, zones)
155 const declaring = await findDeclaring($, root, zones)
156 if (declaring === null) ix.degraded.push('child scan failed (no rg or grep)')
157 const reparsed: { child: string; old: ChildRec; rec: ChildRec }[] = []
158 for (const child of declaring ?? []) {
159 if (clock() - t0 > SWEEP_BUDGET_MS) {
160 ix.degraded.push('sweep incomplete')
161 break
162 }
163 const st = await statOf($, `${root}/${child}`)
164 if (!st) continue
165 const old = prev?.children[child]
166 if (old && old.mtime === st.mtime && old.size === st.size && prev && JSON.stringify(prev.zones) === JSON.stringify(zones)) {
167 // unchanged on disk: keep the parse, but wikilinks may resolve differently now that the file list moved
168 ix.children[child] = { ...old, entries: old.entries.map(e => (e.kind === 'wikilink' ? { ...e, target: resolveWikilink(e.ref, ix.md) } : e)) }
169 continue
170 }
171 try {
172 const text = await $.fs.read(`${root}/${child}`)
173 const rec = buildChild(text, st.mtime, st.size, root, ix.md)
174 if (rec.entries.length) {
175 ix.children[child] = rec
176 if (old) reparsed.push({ child, old, rec })
177 }
178 } catch {
179 ix.degraded.push(`unreadable child: ${child}`)
180 }
181 }
182 if (prev) for (const [t, s] of Object.entries(prev.sources)) if (indexedTargets(ix).includes(t)) ix.sources[t] = s
183 await refreshSources($, ix, indexedTargets(ix))
184 if (log) for (const f of reparsed) log.push(...manualStamps(ix, f.child, f.old, f.rec))
185 ix.sweptAt = clock()
186 if (prev?.injected) ix.injected = prev.injected
187 if (prev?.reviewed) ix.reviewed = prev.reviewed
188 if (prev?.since) ix.since = prev.since
189 // the turn state survives a mid-session scan (/keel scan, /keel on): it is not a new turn
190 if (prev?.turn) ix.turn = prev.turn
191 if (prev?.blocked) ix.blocked = true
192 if (prev?.toasted) ix.toasted = prev.toasted
193 if (prev?.stamped) ix.stamped = prev.stamped
194 const out = trimIndex(ix)
195 try {
196 await $.store.set(indexKey(root), out as never)
197 } catch {
198 out.degraded.push('index not persisted')
199 }
200 return out
201}
202
203export async function loadIndex($: EngineInterface, root: string): Promise<Index | undefined> {
204 try {
205 const v = (await $.store.get(indexKey(root))) as Index | undefined
206 return v && v.v === 1 && v.root === root ? v : undefined
207 } catch {
208 return undefined
209 }
210}
211
212// ── hooks ───────────────────────────────────────────────────────────────────
213
214export const OFF_FILE = '.stoa-keel-off'
215
216async function isOff($: EngineInterface, root: string): Promise<boolean> {
217 try {
218 return await $.fs.exists(`${root}/${OFF_FILE}`)
219 } catch {
220 return false
221 }
222}
223
224async function saveIndex($: EngineInterface, ix: Index): Promise<void> {
225 markInjected(ix, []) // forget what is no longer suspect
226 touchSince(ix, clock())
227 try {
228 await $.store.set(indexKey(ix.root), trimIndex(ix) as never)
229 } catch {
230 if (!ix.degraded.includes('index not persisted')) ix.degraded.push('index not persisted')
231 }
232}
233
234// §9: one line per gesture, appended to journal/keel.md (read + write: `$.fs` has no append). A failure is a degraded note, never an error.
235async function appendJournal($: EngineInterface, ix: Index, log: readonly Gesture[]): Promise<void> {
236 if (log.length === 0) return
237 const path = `${ix.root}/journal/keel.md`
238 try {
239 let old = ''
240 try {
241 old = await $.fs.read(path)
242 } catch {
243 // first gesture: the file does not exist yet
244 }
245 const iso = new Date(clock()).toISOString()
246 await $.fs.write(path, `${old}${old === '' || old.endsWith('\n') ? '' : '\n'}${log.map(g => journalLine(g, iso)).join('\n')}\n`)
247 } catch {
248 if (!ix.degraded.includes('journal not written')) ix.degraded.push('journal not written')
249 }
250}
251
252// ── git blobs (§8, D9) ──────────────────────────────────────────────────────
253
254async function git($: EngineInterface, root: string, argv: string[], stdin?: string): Promise<{ ok: boolean; out: string }> {
255 try {
256 const r = await $.process.run(['git', '--no-pager', ...argv], { cwd: root, ...(stdin === undefined ? {} : { stdin }) })
257 return { ok: r.exitCode === 0, out: r.stdout }
258 } catch {
259 return { ok: false, out: '' }
260 }
261}
262
263// The text a (target, anchor) stamp covers, read from disk now. Null: binary, unreadable, or heading gone.
264async function stampedTextOf($: EngineInterface, ix: Index, target: string, anchor: string | undefined): Promise<string | null> {
265 if (BINARY_EXT.test(target)) return null
266 try {
267 return stampedText(await $.fs.read(absOf(ix.root, target)), anchor)
268 } catch {
269 return null
270 }
271}
272
273// D9: at stamp time the stamped content goes to the git object store (unreferenced loose object: no ref, no commit, no index change),
274// so a later change can be diffed against it. Only content whose hash is the stamp just written is stored. Never fails: a git problem is a degraded note.
275async function storeBlobs($: EngineInterface, ix: Index, ops: readonly { entry: EntryRec; stamp: string }[]): Promise<void> {
276 const seen = new Set<string>()
277 for (const op of ops) {
278 const target = op.entry.target
279 const key = `${target}|${op.entry.anchor ?? ''}|${op.stamp}`
280 if (target === null || seen.has(key)) continue
281 if (seen.size >= BLOB_STORE_MAX) {
282 if (!ix.degraded.includes('git: blob storage capped')) ix.degraded.push('git: blob storage capped')
283 return
284 }
285 seen.add(key)
286 const text = await stampedTextOf($, ix, target, op.entry.anchor)
287 if (text === null || blobSha(text).slice(0, STAMP_LEN) !== op.stamp) continue // the source moved again, or is not text: nothing to keep
288 const r = await git($, ix.root, ['hash-object', '-w', '--stdin'], text)
289 if (!r.ok) {
290 if (!ix.degraded.includes('git: blobs not stored')) ix.degraded.push('git: blobs not stored')
291 return
292 }
293 }
294}
295
296// §8: unified diff of the stamped content against the current one, or `unavailable: <why>`. Writes the current content as a loose object too.
297async function suspectDiff($: EngineInterface, ix: Index, s: Suspect, cap: number): Promise<string> {
298 const target = s.entry.target
299 if (!diffable(s) || target === null) return ''
300 if (BINARY_EXT.test(target)) return 'binary changed'
301 if (!(await git($, ix.root, ['rev-parse', '--is-inside-work-tree'])).ok) return 'unavailable: not a git repo'
302 if (!(await git($, ix.root, ['cat-file', '-e', `${s.old}^{blob}`])).ok) return 'unavailable: stamp too old'
303 const text = await stampedTextOf($, ix, target, s.entry.anchor)
304 if (text === null || blobSha(text).slice(0, STAMP_LEN) !== s.current) return 'unavailable: source moved again'
305 const made = await git($, ix.root, ['hash-object', '-w', '--stdin'], text)
306 if (!made.ok) return 'unavailable: git could not store the current content'
307 const d = await git($, ix.root, ['diff', '--no-color', s.old, made.out.trim()])
308 return (d.ok && trimDiff(d.out, cap)) || 'unavailable: no diff produced'
309}
310
311// D14: the only place keel writes into documents (SessionStart, Stop). Re-reads children that moved on disk (a Bash edit, `@ok` by sed, a hand stamp),
312// applies the stamp plan, re-parses what it wrote (its own write is not a hand stamp), journals every gesture. Returns the children rewritten.
313async function stampBoundary($: EngineInterface, ix: Index, log: Gesture[]): Promise<string[]> {
314 for (const child of Object.keys(ix.children)) {
315 const st = await statOf($, absOf(ix.root, child))
316 const rec = ix.children[child]
317 if (rec && (!st || st.mtime !== rec.mtime || st.size !== rec.size)) await refreshChild($, ix, child, log)
318 }
319 const rewritten: string[] = []
320 const byChild = new Map<string, ReturnType<typeof stampPlan>>()
321 for (const op of stampPlan(ix)) byChild.set(op.child, [...(byChild.get(op.child) ?? []), op])
322 for (const [child, ops] of byChild) {
323 try {
324 const path = absOf(ix.root, child)
325 const r = rewriteStamps(await $.fs.read(path), ops.map(o => ({ entry: o.entry, stamp: o.stamp })))
326 if (r.failed.length && !ix.degraded.includes(`stamp skipped: ${child}`)) ix.degraded.push(`stamp skipped: ${child}`)
327 if (r.done.length === 0) continue
328 await $.fs.write(path, r.text)
329 rewritten.push(child)
330 for (const i of r.done) log.push(gestureOf(ops[i]!.kind, child, ops[i]!.entry, ops[i]!.stamp))
331 await storeBlobs($, ix, r.done.map(i => ops[i]!))
332 await refreshChild($, ix, child)
333 } catch {
334 if (!ix.degraded.includes(`stamp failed: ${child}`)) ix.degraded.push(`stamp failed: ${child}`)
335 }
336 }
337 delete ix.reviewed
338 await appendJournal($, ix, log)
339 return rewritten
340}
341
342type Told = { context: string[]; watchPaths: string[]; block?: string }
343const NOTHING: Told = { context: [], watchPaths: [] }
344
345const names = (paths: readonly string[]): string => (paths.length > 3 ? `${paths.slice(0, 3).join(', ')} and ${paths.length - 3} more` : paths.join(', '))
346
347// The first FEEDBACK_DIFFS `changed` suspects of a list, each with its capped diff (PostToolUse feedback and prompt.submit share the caps).
348async function diffBlocks($: EngineInterface, ix: Index, list: readonly Suspect[]): Promise<string[]> {
349 const diffs: string[] = []
350 for (const s of list.filter(diffable).slice(0, FEEDBACK_DIFFS)) {
351 const d = await suspectDiff($, ix, s, FEEDBACK_DIFF_CAP)
352 if (d) diffs.push(`diff ${s.entry.ref}${s.entry.anchor ? `#${s.entry.anchor}` : ''} (for ${s.child}):\n${d}`)
353 }
354 return diffs
355}
356
357// Suspects hanging off the sources that just moved and not yet told: one block for the model (PostToolUse), recorded as injected.
358// The first FEEDBACK_DIFFS `changed` ones carry their diff (§1: the agent is told, with the diff).
359async function feedback($: EngineInterface, ix: Index, moved: readonly string[]): Promise<string[]> {
360 if (moved.length === 0) return []
361 const list = newSuspects(ix, derivedFrom(ix, moved))
362 const text = formatSuspects(`keel: ${names(moved)} changed; derived files now suspect:`, list, INJECT_CAP_PROMPT)
363 if (!text) return []
364 markInjected(ix, list)
365 return [[text, ...(await diffBlocks($, ix, list)), GUIDE].join('\n')]
366}
367
368// L1 at SessionStart (any source), then the stamping boundary. Tells the model every current suspect (cap 4000) and asks the host to watch the indexed sources (L4).
369async function onSessionStart($: EngineInterface, root: string): Promise<Told> {
370 if (await isOff($, root)) return NOTHING
371 const log: Gesture[] = []
372 const ix = await sweepFull($, root, await loadIndex($, root), log)
373 noteStamped(ix, await stampBoundary($, ix, log))
374 const all = suspectsOf(ix)
375 ix.injected = []
376 markInjected(ix, all)
377 startTurn(ix)
378 await saveIndex($, ix)
379 const text = formatSuspects('keel: derived files whose sources changed, not yet reviewed:', all, INJECT_CAP_START)
380 return { context: text ? [`${text}\n${GUIDE}`] : [], watchPaths: watchTargets(ix) }
381}
382
383// L2: a Write/Edit landed on `path`. A source is re-hashed; a markdown file that may declare `sources:` is re-parsed (it may be new).
384// `tell` false (FileChanged): the index moves, the model is told at the next prompt.
385async function onFileWritten($: EngineInterface, root: string, path: string, tell = true): Promise<string[]> {
386 if (await isOff($, root)) return []
387 const ix = await loadIndex($, root)
388 if (!ix) return []
389 const target = toTarget(root, path)
390 if (!target.startsWith('/') && hasSegment(target, ix.zones.ignore)) return []
391 const moved = ix.sources[target] ? await refreshSources($, ix, [target]) : []
392 const log: Gesture[] = []
393 if (/\.md$/i.test(target) && !target.startsWith('/')) {
394 if (!ix.md.includes(target)) ix.md = [...ix.md, target].sort()
395 await refreshChild($, ix, target, log)
396 await refreshSources($, ix, indexedTargets(ix).filter(t => !ix.sources[t]))
397 if (tell) markReviewed(ix, target) // the agent wrote this child: its changed entries are reviewed (§3.4 `updated`), stamped at the boundary
398 }
399 const told = tell ? await feedback($, ix, moved) : []
400 await appendJournal($, ix, log)
401 await saveIndex($, ix)
402 return told
403}
404
405// L3: a Bash command ran, anything may have moved. Stat sweep of every indexed source.
406async function onBash($: EngineInterface, root: string): Promise<string[]> {
407 if (await isOff($, root)) return []
408 const ix = await loadIndex($, root)
409 if (!ix) return []
410 const told = await feedback($, ix, await sweepSources($, ix))
411 await saveIndex($, ix)
412 return told
413}
414
415// §9: keel-status-llm.md at the root, rewritten at Stop while the index is non-empty (idle rule), untouched when only the sweep time moved.
416async function writeStatus($: EngineInterface, ix: Index): Promise<void> {
417 if (isIdle(ix)) return
418 const path = `${ix.root}/keel-status-llm.md`
419 try {
420 const next = statusText(ix, new Date(clock()).toISOString())
421 let old = ''
422 try {
423 old = await $.fs.read(path)
424 } catch {
425 // first write: the file does not exist yet
426 }
427 if (old === '' || !sameStatus(old, next)) await $.fs.write(path, next)
428 } catch {
429 if (!ix.degraded.includes('status file not written')) ix.degraded.push('status file not written')
430 }
431}
432
433// The human channel: a transient toast, never an error.
434function toast($: EngineInterface, text: string): void {
435 try {
436 $.ui.toast(text, { timeoutMs: 6000 })
437 } catch {
438 // nothing left to do
439 }
440}
441
442// Stop: L3 final sweep, the stamping boundary, then D10: the suspects created this turn and still suspect are blocked ONCE (not again when the
443// host says it is re-entering, nor once the block is spent this turn). Returns the block text, if any. The toast and the status file are written either way.
444async function onStop($: EngineInterface, root: string, again: boolean): Promise<string | undefined> {
445 if (await isOff($, root)) return undefined
446 const ix = await loadIndex($, root)
447 if (!ix) return undefined
448 await sweepSources($, ix)
449 const rewritten = await stampBoundary($, ix, [])
450 const created = createdThisTurn(ix)
451 let block: string | undefined
452 if (created.length && !again && !ix.blocked) {
453 const shown = created.slice(0, STOP_MAX)
454 const items: { s: Suspect; diff: string }[] = []
455 for (const s of shown) items.push({ s, diff: diffable(s) ? await suspectDiff($, ix, s, DIFF_CAP) : '' })
456 block = formatStopBlock(items, created.length - shown.length, rewritten)
457 ix.blocked = true
458 markInjected(ix, shown) // the block told them: the next prompt does not repeat it
459 }
460 // one toast per text and turn: the Stop that re-enters after a block would stack an identical one
461 const parts = [...(created.length ? [`${created.length} derived file${created.length > 1 ? 's' : ''} to review`] : []), ...(rewritten.length ? [`stamped ${names(rewritten)}`] : [])]
462 const note = parts.length ? `keel: ${parts.join('; ')}` : undefined
463 const fresh = note !== undefined && ix.toasted !== note
464 if (fresh) ix.toasted = note
465 if (!block) noteStamped(ix, rewritten) // a block already names them; otherwise the next prompt does
466 await saveIndex($, ix)
467 await writeStatus($, ix)
468 if (fresh) toast($, note)
469 return block
470}
471
472// L4 fallback: stat sweep at every prompt (a file the host does not watch may have moved), then the NEW suspects only (cap 2000).
473async function onPrompt($: EngineInterface, root: string): Promise<string[]> {
474 if (await isOff($, root)) return []
475 const ix = await loadIndex($, root)
476 if (!ix) return []
477 await sweepSources($, ix)
478 const notes: string[] = []
479 if (ix.stamped?.length) {
480 notes.push(formatStamped(ix.stamped))
481 delete ix.stamped
482 }
483 const list = newSuspects(ix)
484 const text = formatSuspects('keel: derived files whose sources changed, not yet reviewed:', list, INJECT_CAP_PROMPT)
485 if (text) {
486 markInjected(ix, list)
487 notes.push([text, ...(await diffBlocks($, ix, list)), GUIDE].join('\n'))
488 }
489 startTurn(ix)
490 await saveIndex($, ix)
491 return notes
492}
493
494// ── /keel (§10) ─────────────────────────────────────────────────────────────
495
496// The command is declared from keel's `session.*` glob (branch on `session.start`): pack owns the exact `session.start`, and the host throws on a duplicate
497// (pattern, matcher). Declared from `classic.SessionStart` it landed too late: `claude -p "/keel"` answered "isn't installed". Also while off: `/keel on` must stay reachable.
498async function declareCommand($: EngineInterface): Promise<void> {
499 try {
500 await $.command.register({ name: 'keel', description: 'Derived files whose sources changed: suspects, scan, why <file>, off, on', argumentHint: '[scan | why <file> | off | on]' })
501 } catch (err) {
502 try {
503 $.ui.log(`keel: command keel not registered: ${String(err)}`, { to: 'debug' })
504 } catch {
505 // nothing left to do
506 }
507 }
508}
509
510const USAGE = 'usage: /keel | /keel scan | /keel why <file> | /keel off | /keel on'
511const isoOf = (ms: number): string => new Date(ms).toISOString()
512
513// Re-scans the chantier (L1, no stamping: stamps are written at the turn boundaries, D14) and saves the index.
514async function rescan($: EngineInterface, root: string): Promise<{ ix: Index; added: string[]; removed: string[] }> {
515 const prev = await loadIndex($, root)
516 const log: Gesture[] = []
517 const ix = await sweepFull($, root, prev, log)
518 await appendJournal($, ix, log)
519 await saveIndex($, ix)
520 const was = Object.keys(prev?.children ?? {})
521 const now = Object.keys(ix.children)
522 return { ix, added: now.filter(c => !was.includes(c)), removed: was.filter(c => !now.includes(c)) }
523}
524
525const OFF_TEXT = (root: string): string => `keel: off in ${root} (${OFF_FILE} present). /keel on to resume.`
526
527async function keelCommand($: EngineInterface, args: string): Promise<{ text: string }> {
528 try {
529 const root = await $.session.cwd()
530 const [sub = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
531 const arg = rest.join(' ')
532 const off = await isOff($, root)
533 if (sub === 'off') {
534 if (off) return { text: `keel: already off in ${root}.` }
535 await $.fs.write(`${root}/${OFF_FILE}`, 'keel is off in this directory: every keel hook is a no-op. Delete this file, or run /keel on, to resume.\n')
536 return { text: `keel: off in ${root} (created ${OFF_FILE}). /keel on to resume.` }
537 }
538 if (sub === 'on') {
539 if (!off) return { text: `keel: was not off in ${root}.` }
540 await run($, root, ['/bin/rm', '-f', `${root}/${OFF_FILE}`])
541 if (await isOff($, root)) return { text: `keel: could not remove ${OFF_FILE} in ${root}; delete it by hand.` }
542 const { ix } = await rescan($, root) // the hooks were no-ops while off: the index may be stale
543 return { text: `keel: on in ${root}; rescanned ${Object.keys(ix.children).length} derived file(s). ${suspectsOf(ix).length} suspect(s).` }
544 }
545 if (sub !== '' && sub !== 'scan' && sub !== 'why') return { text: `keel: unknown subcommand "${sub}". ${USAGE}` }
546 if (off) return { text: OFF_TEXT(root) }
547 if (sub === 'scan') {
548 const { ix, added, removed } = await rescan($, root)
549 const pending = stampPlan(ix).length
550 const head = [`keel scan: ${Object.keys(ix.children).length} derived file(s), ${indexedTargets(ix).length} source(s).`]
551 if (added.length) head.push(`found: ${names(added)}`)
552 if (removed.length) head.push(`gone: ${names(removed)}`)
553 if (pending) head.push(`${pending} stamp(s) pending: written at the end of the turn.`)
554 return { text: `${head.join(' ')}\n${formatOverview(ix, isoOf)}` }
555 }
556 const ix = await loadIndex($, root)
557 if (!ix) return { text: `keel: no index yet for ${root}; run /keel scan.` }
558 if (sub === 'why') {
559 if (!arg) return { text: `keel: why needs a file. ${USAGE}` }
560 const wiki = /^\[\[(.+)\]\]$/.exec(arg)
561 const target = wiki ? resolveWikilink(wiki[1]!, ix.md) : toTarget(root, arg)
562 if (target === null) return { text: `keel: ${arg} is unresolved (no file, or several, with that name).` }
563 await sweepSources($, ix)
564 await saveIndex($, ix)
565 return { text: formatWhy(ix, arg, target) }
566 }
567 await sweepSources($, ix)
568 await saveIndex($, ix)
569 return { text: formatOverview(ix, isoOf) }
570 } catch (err) {
571 return { text: `keel: command failed: ${String(err)}` }
572 }
573}
574
575const WRITE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
576
577const filePathOf = (input: unknown): string | null => {
578 const p = typeof input === 'object' && input !== null ? (input as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (input as { notebook_path?: unknown }).notebook_path : undefined
579 return typeof p === 'string' && p.startsWith('/') ? p : null
580}
581
582// One glob hook on `classic.*`: the host throws on a duplicate (pattern, matcher) and pack owns `classic.SessionStart`, so keel branches on `next.is`.
583// It sees every classic event: anything that is not ours returns before any `$` call. `prompt.submit` is not pack's, so it is registered on its own.
584export function registerKeel(on: On): void {
585 on('classic.*', async ($, e, next) => {
586 let told: Told = NOTHING
587 try {
588 if (next.is('classic.SessionStart', e)) told = await onSessionStart($, e.cwd)
589 else if (next.is('classic.PostToolUse', e)) {
590 if (e.tool_name === 'Bash') told = { context: await onBash($, e.cwd), watchPaths: [] }
591 else if (WRITE_TOOLS.includes(e.tool_name)) {
592 const path = filePathOf(e.tool_input)
593 if (path) told = { context: await onFileWritten($, e.cwd, path), watchPaths: [] }
594 }
595 } else if (next.is('classic.FileChanged', e)) await onFileWritten($, e.cwd, e.file_path, false)
596 else if (next.is('classic.Stop', e)) {
597 const block = await onStop($, e.cwd, e.stop_hook_active)
598 if (block) told = { context: [], watchPaths: [], block }
599 }
600 } catch (err) {
601 try {
602 $.ui.log(`keel: ${next.event} failed: ${String(err)}`, { to: 'debug' })
603 } catch {
604 // nothing left to do
605 }
606 }
607 const r = await next(e)
608 if (told.context.length === 0 && told.watchPaths.length === 0 && !told.block) return r
609 return {
610 ...r,
611 ...(told.block ? { block: r.block ? `${r.block}\n${told.block}` : told.block } : {}),
612 ...(told.context.length ? { additionalContext: [...(r.additionalContext ?? []), ...told.context] } : {}),
613 ...(told.watchPaths.length ? { watchPaths: [...(r.watchPaths ?? []), ...told.watchPaths] } : {}),
614 }
615 }).catch(($, e, next) => {
616 try {
617 $.ui.log(`keel: hook failed: ${String(next.error)}`, { to: 'debug' })
618 } catch {
619 // nothing left to do
620 }
621 return next(e)
622 })
623 on('session.*', async ($, e, next) => {
624 if (next.is('session.start', e)) await declareCommand($)
625 return next(e)
626 }).catch(($, e, next) => {
627 try {
628 $.ui.log(`keel: session hook failed: ${String(next.error)}`, { to: 'debug' })
629 } catch {
630 // nothing left to do
631 }
632 return next(e)
633 })
634 on('command.run', { command: 'keel' }, ($, e) => keelCommand($, e.args))
635 on('prompt.submit', async ($, e, next) => {
636 let context: string[] = []
637 try {
638 context = await onPrompt($, await $.session.cwd())
639 } catch (err) {
640 try {
641 $.ui.log(`keel: prompt.submit failed: ${String(err)}`, { to: 'debug' })
642 } catch {
643 // nothing left to do
644 }
645 }
646 return next(context.length ? { ...e, context: [...(e.context ?? []), ...context] } : e)
647 }).catch(($, e, next) => {
648 try {
649 $.ui.log(`keel: prompt.submit hook failed: ${String(next.error)}`, { to: 'debug' })
650 } catch {
651 // nothing left to do
652 }
653 return next(e)
654 })
655}
656hooks/pack.tsx 1701 lines1import type { EngineInterface, On } from 'claude-code'
2
3// pack (stoa), ported from the modtest prototype: one mod for context.
4// /pack save [stream] fork -> pack-<stream>-llm.md (not awaited, no clear)
5// /pack [stream] save (awaited) + arm + /clear (automatic) + re-inject; --yes|-y = silent alias
6// /pack [stream] --review save (awaited) + review pane: clear & continue [c] / keep working [x]
7// /pack auto [off] toggle auto mode: .stoa-pack-auto in the session cwd
8// /pack cancel leave the review or the armed state (the saved file stays)
9// /unpack <stream> state file -> first message of this session
10// Journal: every `<!-- ckpt ... -->` trailer of a main-loop answer is appended verbatim to journal/<stream>.md by code.
11// The fork DESIGNATES (routing ids + fork-owned fields); code COPIES trailer text and assembles the state file.
12// Plan: /praxis/repos/agent-skills/plan-self-relay-mod-llm.md
13
14const PANE = 'pack'
15const STORE_KEY = 'pack:pending'
16const STALE_MS = 10 * 60 * 1000
17const MARKDOWN_MAX = 10_000
18// Hard cap of the state file, enforced by code: the fork is told the ceiling and the room left (forkPrompt), assemble() cuts what still overflows.
19const STATE_MAX = 8_000
20// Below this much room under STATE_MAX, the fork is told to designate retire_* ids first.
21const ROOM_TIGHT = 1_500
22const STREAM_RE = /^[a-zA-Z0-9_-]{1,50}$/
23const RESERVED = ['save', 'load', 'cancel', 'auto']
24const SHRINK_LINE = 80
25// Auto mode toggle: this file in the session cwd (same convention as keel's .stoa-keel-off). Shell touch / rm are valid too.
26const AUTO_FILE = '.stoa-pack-auto'
27// $.agent.list() is raced against this: a hang would leave a save (and auto mode) stuck without a word.
28const AGENT_LIST_MS = 5_000
29// Auto mode: failed saves (GATE blocked...) retried at the next turn end, per cycle; then the advice line alone.
30const AUTO_TRIES = 3
31// Auto mode: seconds between the saved pack and the /clear, to type a prompt or /pack cancel and stay.
32const AUTO_GRACE_MS = 10_000
33// Feedback channels. Status line = the present state; a toast = an event; a {text} reply = the record (past tense).
34// A "..." on the status always ends in an outcome on the status. Errors stay on the status and toast for ERROR_TOAST_MS.
35const ERROR_TOAST_MS = 12_000
36// A flow line older than this says it may be stuck ($.model.fork cannot be cancelled).
37export const SLOW_MS = 3 * 60 * 1000
38// Context-fill floors (T19, T21, T22), as a share of the model's context window (e.context.window), the same figure as the
39// status line. To calibrate live from the `pack: ctx` log lines.
40// STRONG: floor for a strong seam (a task closed, a pivot). SOFT: floor for a simple seam (a decision). HARD: auto pre-save.
41export const STRONG = 0.3
42export const SOFT = 0.5
43export const HARD = 0.7
44// Safety cap on HARD: the pre-save must happen before auto-compaction (the fork is refused at the wall), so the hard zone
45// starts at min(HARD * window, WALL_CAP * wall).
46export const WALL_CAP = 0.95
47// usage() (the wall) is only asked once tokens / window reaches this, then cached. Under it a strong seam at STRONG needs no call, and a
48// wall of at least 0.35 / WALL_CAP = 37% of the window is still seen before the cap bites.
49const USAGE_FROM = 0.35
50
51// UNPACK_RULES lines 1-4: /repos/agent-skills/team/skills/relay/SKILL.md §3 (md5 615b560cfe2e88c19763e668bf89570a at copy time).
52// Line 5 (orchestrator, team-only) replaced by a solo line; line 6 added for v2 (read_first after the go).
53const UNPACK_RULES = `Reply with one line saying you are ready, then stop there.
54Do nothing without my explicit go — no file read, no command, no sub-agent, no continuation of what is in progress.
55Do not read the pack back to me. One line of acknowledgement is the whole answer.
56Write a message only when something changes. The rest of the time stay silent.
57Questions go to me, in this window.
58After my go, read the \`read_first\` files before acting.`
59
60const FORK_PROMPT = `You are writing the state of this work stream for yourself. After a /clear the state file is the ONLY thing the fresh context receives. Code assembles the file: you DESIGNATE, you never copy. Lines coming from ckpt trailers are routed and copied verbatim by code.
61First line, exactly one of:
62GATE: ok
63GATE: blocked - <one-line reason>
64Blocked = you are in the middle of a cross-item step (synthesis, deduplication, global arbitration across all units). Then output nothing else.
65If ok, from line 2 output ONLY the block below, keys in this order, no markdown, no prose, no blank line. Scalar = one line \`key: value\`. List = \`key:\` then one \`- item\` line per item; a missing list = empty.
66status: <one word or short phrase: building | blocked | review ...>
67goal: <1 sentence, carried from PREVIOUS STATE unless it changed>
68read_first:
69- <path — role> (closed list, read only after the human's go, never before the go)
70read_if_needed:
71- <path or URL — when to read it> (extras only)
72deliverable: <path where the written work lives>
73decisions:
74- <choice — why> (ONLY decisions no trailer covers; the why is mandatory; do not reopen)
75learnings:
76- <non-obvious fact — command or path that proves it> (extras only)
77discarded:
78- <path tried — why rejected> (extras only)
79in_progress:
80- <half-done item — where it stands>
81next:
82- <action> (2-4 actions, extras only)
83unknowns:
84- <what is not known> (never empty)
85stale:
86- <path — replaced by X; do not reload>
87retire_answered: <ids, e.g. c03, c07>
88retire_done: <ids>
89retire_learned: <ids>
90retire_reversed: <ids, e.g. c05>
91retire_superseded: <ids>
92Rules:
93- Every list except read_if_needed, learnings, discarded, next and decisions is yours entirely, and for those five you give EXTRAS only: whatever the trailers below do not already say.
94- Return the COMPLETE current content of every list you own: carry forward the still-valid lines of PREVIOUS STATE that have no (cNN) suffix.
95- A line ending with (cNN) belongs to code: never output one.
96- Retire keys take ids from PREVIOUS STATE or from the entries below. retire_answered: open: / assumption: items now resolved. retire_done: next items now done. retire_learned: learnings now obsolete (a bug since fixed, a fact replaced by a later one). retire_reversed: decisions proved wrong and reversed. retire_superseded: decisions overtaken by a later decision without being wrong. Omit a retire key when it is empty.
97- A pointer (absolute path, path:line, command) replaces any explanation. Do not run commands; write them.`
98
99type Phase = 'idle' | 'review' | 'armed'
100type Pending = { pack: string; createdAt: number; cwd: string; stream?: string }
101type Stored = Pending & { phase: Phase }
102
103// Module scope: survives /clear (module not reloaded), lost on mod reload -> $.store backup.
104let phase: Phase = 'idle'
105let pending: Pending | null = null
106// A save or a pack build is running (the fork is slow): refuse a second command meanwhile.
107let saving = false
108// Stream binding. Same session across /clear, so it survives /clear; reset on startup/resume/fork.
109let boundArg: string | null = null
110let titleStream: string | null = null
111// Journal appends of this process, one at a time.
112let journalQueue: Promise<unknown> = Promise.resolve()
113// T19 context zones. Module scope like the rest: survives /clear (reset by hand there), lost on mod reload.
114let wallCache: number | null = null
115let lastM: Measure | null = null
116let seam: Seam = { level: 'none' }
117// softDone: the advice toast was shown in this cycle. hardDone: the hard zone acted in this cycle.
118let softDone = false
119let hardDone = false
120// The persistent advice line (T21) and the line of the active save/pack flow: one `$.ui.status` per plugin, see paint().
121let advice: Advice | null = null
122let flowLine: string | undefined
123// Ticks the elapsed time on the flow line; any other flow line stops it.
124let ticker: { cancel: () => void } | null = null
125// Last outcome of a command, under the flow line. ok: goes at the end of the next prompted turn. sticky (warning, error): until the next command.
126type Outcome = { text: string; sticky: boolean; prompted: boolean }
127let outcome: Outcome | null = null
128// Checkpoints kept in the store and not yet in a journal (no stream, or a failed write).
129type Waiting = { count: number; stream: string | null }
130let waiting: Waiting | null = null
131// Bumped at every re-arm: a hard save finishing in an older cycle must not set a stale advice.
132let cycle = 0
133// Auto mode: .stoa-pack-auto exists in the session cwd. Refreshed from the disk at session start, after a toggle and at trigger time.
134let autoOn = false
135// Auto pack in flight (save, then grace before the /clear). aborted: a prompt or /pack cancel came in; graceStarted: armed and counting down.
136type AutoRun = { aborted: boolean; graceStarted: boolean }
137let autoRun: AutoRun | null = null
138// Failed auto saves in this cycle (reset by rearmZones).
139let autoTries = 0
140// A turn ended while a save was running: the auto pack starts again when that save ends (the running one lacks the end of the turn).
141let autoDue = false
142// The hard save was deferred to the turn end because auto mode is on; auto off gives it back to the measure.
143let hardDeferred = false
144// The main loop is between two turns: set at a main-loop turn.complete (not aborted), cleared by turn.start (main loop only, a subagent's run raises none).
145// Live, session.measure of a turn arrives AFTER its turn.complete: a measure seen while this is set belongs to the turn that just ended, so the
146// auto pack may start on it (no turn runs, a /clear kills nothing). A measure seen mid-turn never does (A7).
147let turnOver = false
148
149// ---------- pure helpers ----------
150
151export const pad = (n: number) => String(n).padStart(2, '0')
152export const statePath = (stream: string) => `pack-${stream}-llm.md`
153// stoa 0.1.0 and modtest saved the state under this name (header field `predecessor` for previous_session): read on a miss, written forward.
154export const legacyStatePath = (stream: string) => `relay-${stream}-llm.md`
155// Outcome of the last save, rewritten at every save: the toast reaches the human only, this file the model too.
156export const statusPath = (stream: string) => `pack-${stream}-status-llm.md`
157export const journalPath = (stream: string) => `journal/${stream}.md`
158const bufferKey = (sid: string) => `pack:buffer:${sid}`
159
160export function journalRule(stream: string): string {
161 return `Journal ${journalPath(stream)}: never read it whole; to see an entry, grep by id: grep -A8 '^### cNN' ${journalPath(stream)}`
162}
163
164// A /rename title that is not a valid stream name becomes one; empty = no stream.
165export function slugify(title: string): string | null {
166 if (STREAM_RE.test(title)) return title
167 const slug = title
168 .toLowerCase()
169 .replace(/[^a-z0-9_-]+/g, '-')
170 .replace(/^-+|-+$/g, '')
171 .slice(0, 50)
172 .replace(/-+$/, '')
173 return slug === '' ? null : slug
174}
175
176export type Verb = 'save' | 'load' | 'pack' | 'cancel' | 'auto'
177export type ParsedArgs = { verb: Verb; stream?: string; review: boolean; off?: boolean } | { error: string }
178
179const PACK_USAGE = 'Usage: /pack [stream] [--review] | save [stream] | cancel | auto [off]'
180
181export function parseArgs(args: string): ParsedArgs {
182 const tokens = args.split(/\s+/).filter(t => t !== '')
183 const review = tokens.includes('--review')
184 // --yes / -y: silent alias of the default (the fresh start is what /pack does now).
185 const yes = tokens.some(t => t === '--yes' || t === '-y')
186 const rest = tokens.filter(t => t !== '--review' && t !== '--yes' && t !== '-y')
187 const bad = rest.find(t => t.startsWith('-'))
188 if (bad) return { error: `unknown option ${bad}. ${PACK_USAGE}` }
189 if (review && yes) return { error: `--review and --yes exclude each other. ${PACK_USAGE}` }
190 const flagged = review || yes
191 let verb: Verb = 'pack'
192 if (rest[0] === 'load') return { error: 'load moved: type /unpack <stream>' }
193 if (rest[0] === 'cancel') return flagged || rest.length > 1 ? { error: 'cancel takes nothing else. Usage: /pack cancel' } : { verb: 'cancel', review: false }
194 if (rest[0] === 'auto') {
195 if (flagged || rest.length > 2 || (rest.length === 2 && rest[1] !== 'off')) return { error: 'Usage: /pack auto [off]' }
196 return { verb: 'auto', review: false, off: rest[1] === 'off' }
197 }
198 if (rest[0] === 'save') verb = rest.shift() as Verb
199 if (flagged && verb !== 'pack') return { error: `${review ? '--review' : '--yes'} only goes with the full form (/pack [stream] [--review]), not /pack save` }
200 if (rest.length > 1) return { error: `too many arguments. ${PACK_USAGE}` }
201 const stream = rest[0]
202 if (stream !== undefined) {
203 if (RESERVED.includes(stream)) return { error: `"${stream}" is a reserved word, not a stream name` }
204 if (!STREAM_RE.test(stream)) return { error: `invalid stream "${stream}": use 1-50 chars of a-z A-Z 0-9 _ -` }
205 }
206 return { verb, stream, review }
207}
208
209// `/unpack <stream>`: the stream is required; the load verb is the same one /pack used to carry.
210export function parseUnpackArgs(args: string): ParsedArgs {
211 const tokens = args.split(/\s+/).filter(t => t !== '')
212 const bad = tokens.find(t => t.startsWith('-'))
213 if (bad) return { error: `unknown option ${bad}. Usage: /unpack <stream>` }
214 if (tokens.length === 0) return { error: 'no stream given. Usage: /unpack <stream>' }
215 if (tokens.length > 1) return { error: `too many arguments. Usage: /unpack <stream>` }
216 const stream = tokens[0] as string
217 if (RESERVED.includes(stream)) return { error: `"${stream}" is a reserved word, not a stream name` }
218 if (!STREAM_RE.test(stream)) return { error: `invalid stream "${stream}": use 1-50 chars of a-z A-Z 0-9 _ -` }
219 return { verb: 'load', stream, review: false }
220}
221
222export type Clause = 'decision' | 'reasoning' | 'learning' | 'pivot' | 'rejected' | 'constraint' | 'assumption' | 'open' | 'definition' | 'refs'
223export type Section = 'read_first' | 'read_if_needed' | 'decisions' | 'learnings' | 'discarded' | 'in_progress' | 'next' | 'unknowns' | 'stale'
224
225const CLAUSES: Clause[] = ['decision', 'reasoning', 'learning', 'pivot', 'rejected', 'constraint', 'assumption', 'open', 'definition', 'refs']
226// null = journal only (addressable by id, never in the state).
227export const ROUTE: Record<Clause, Section | null> = {
228 decision: 'decisions',
229 constraint: 'decisions',
230 learning: 'learnings',
231 definition: 'learnings',
232 rejected: 'discarded',
233 open: 'next',
234 assumption: 'unknowns',
235 refs: 'read_if_needed',
236 reasoning: null,
237 pivot: null,
238}
239
240// Splits a trailer body on its clause keywords; separators (` · `, `|`, `;`, newlines) are stripped.
241export function parseTrailer(trailer: string): { type: Clause; text: string }[] {
242 const body = trailer.replace(/^\s*<!--\s*ckpt/, '').replace(/-->\s*$/, '')
243 const re = new RegExp(`(?:^|[·|;\\n])\\s*(${CLAUSES.join('|')}):\\s*`, 'g')
244 const hits = [...body.matchAll(re)]
245 const out: { type: Clause; text: string }[] = []
246 hits.forEach((hit, i) => {
247 const start = (hit.index ?? 0) + hit[0].length
248 const end = i + 1 < hits.length ? (hits[i + 1].index ?? body.length) : body.length
249 const text = body.slice(start, end).replace(/[\s·|;]+$/, '').replace(/\s+/g, ' ').trim()
250 if (text) out.push({ type: hit[1] as Clause, text })
251 })
252 return out
253}
254
255export type Entry = { n: number; id: string; trailer: string }
256
257export function parseJournal(text: string): Entry[] {
258 const heads = [...text.matchAll(/^### c(\d+) .*$/gm)]
259 return heads.map((h, i) => {
260 const from = (h.index ?? 0) + h[0].length
261 const to = i + 1 < heads.length ? (heads[i + 1].index ?? text.length) : text.length
262 return { n: Number(h[1]), id: `c${h[1]}`, trailer: text.slice(from, to).trim() }
263 })
264}
265
266const maxId = (text: string) => parseJournal(text).reduce((m, e) => Math.max(m, e.n), 0)
267
268const ID_END = /\s\(c(\d+)\)\s*$/
269const idOf = (line: string): number | null => {
270 const m = line.match(ID_END)
271 return m ? Number(m[1]) : null
272}
273const stripId = (line: string) => line.replace(ID_END, '')
274
275const SCALARS_HEADER = ['stream', 'saved', 'status', 'previous_session', 'goal', 'journal', 'journal_cursor']
276const LISTS: Section[] = ['read_first', 'read_if_needed', 'decisions', 'learnings', 'discarded', 'in_progress', 'next', 'unknowns', 'stale']
277// Body order of the state file (deliverable is the only scalar among them).
278const BODY_ORDER = ['read_first', 'read_if_needed', 'deliverable', 'decisions', 'learnings', 'discarded', 'in_progress', 'next', 'unknowns', 'stale']
279
280export type Keyed = { scalars: Record<string, string>; lists: Record<string, string[]> }
281
282// `key: value` scalars and `key:` + `- item` lists; unknown lines are ignored.
283export function parseKeyed(text: string, scalarKeys: string[], listKeys: string[]): Keyed {
284 const out: Keyed = { scalars: {}, lists: {} }
285 let current: string | null = null
286 for (const raw of text.split('\n')) {
287 const line = raw.trimEnd()
288 const key = line.match(/^([a-z_]+):\s*(.*)$/)
289 if (key && scalarKeys.includes(key[1])) {
290 out.scalars[key[1]] = key[2].trim()
291 current = null
292 } else if (key && listKeys.includes(key[1])) {
293 out.lists[key[1]] = out.lists[key[1]] ?? []
294 current = key[1]
295 if (key[2].trim()) out.lists[current].push(key[2].trim())
296 } else if (current && line.startsWith('- ')) {
297 out.lists[current].push(line.slice(2).trim())
298 }
299 }
300 return out
301}
302
303const STATE_SCALARS = [...SCALARS_HEADER, 'deliverable']
304// `predecessor`: previous_session in a legacy state file, parsed so the migration keeps it.
305const parseState = (text: string) => parseKeyed(text, [...STATE_SCALARS, 'predecessor'], LISTS)
306
307const RETIRE_KEYS = ['retire_answered', 'retire_done', 'retire_learned', 'retire_reversed', 'retire_superseded'] as const
308const FORK_SCALARS = ['status', 'goal', 'deliverable', ...RETIRE_KEYS]
309export type ForkFields = {
310 fields: Keyed
311 answered: Set<number>
312 done: Set<number>
313 learned: Set<number>
314 reversed: Set<number>
315 superseded: Set<number>
316}
317export type ForkParse = { kind: 'ok'; body: string } | { kind: 'blocked'; reason: string }
318
319export function parseFork(text: string): ForkParse {
320 const lines = text.trim().split('\n')
321 const first = (lines[0] ?? '').trim()
322 if (first === 'GATE: ok') {
323 const body = lines.slice(1).join('\n').trim()
324 return body ? { kind: 'ok', body } : { kind: 'blocked', reason: 'empty fork output' }
325 }
326 const blocked = first.match(/^GATE:\s*blocked\s*[-—:]?\s*(.*)$/)
327 if (blocked) return { kind: 'blocked', reason: blocked[1] || 'no reason given' }
328 return { kind: 'blocked', reason: `malformed first line: ${first.slice(0, 80)}` }
329}
330
331export function parseForkBody(body: string): ForkFields | null {
332 const fields = parseKeyed(body, FORK_SCALARS, LISTS)
333 if (Object.keys(fields.scalars).length === 0 && Object.keys(fields.lists).length === 0) return null
334 const ids = (s: string | undefined) => new Set([...(s ?? '').matchAll(/c(\d+)/g)].map(m => Number(m[1])))
335 return {
336 fields,
337 answered: ids(fields.scalars.retire_answered),
338 done: ids(fields.scalars.retire_done),
339 learned: ids(fields.scalars.retire_learned),
340 reversed: ids(fields.scalars.retire_reversed),
341 superseded: ids(fields.scalars.retire_superseded),
342 }
343}
344
345const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
346const AGENT_LINE = 'agent in flight:'
347// The list did not answer in AGENT_LIST_MS: said once in the pack instead of silently listing nobody.
348const AGENTS_UNKNOWN = 'agents: unknown (list timed out)'
349const AGENTS_MAX = 10
350const AGENT_DESC_MAX = 100
351const LIVE_STATUS = ['pending', 'running', 'waiting', 'idle']
352// One code-written line per live background agent, so the fresh context after /clear knows whom it can still SendMessage.
353// Agents of a Workflow are not in $.agent.list (a forked skill is). Called BEFORE the save fork starts, so the fork never
354// lists itself. A failure -> no lines; a list slower than AGENT_LIST_MS -> one `unknown` line. The save never fails because of this.
355async function agentLines($: EngineInterface): Promise<string[]> {
356 let timer: { cancel: () => void } | null = null
357 try {
358 const timeout = new Promise<'timeout'>(resolve => {
359 timer = $.clock.after(AGENT_LIST_MS, () => resolve('timeout'))
360 })
361 const listed = await Promise.race([$.agent.list(), timeout])
362 if (listed === 'timeout') return [AGENTS_UNKNOWN]
363 const live = listed.filter(a => LIVE_STATUS.includes(a.status))
364 const lines = live.slice(0, AGENTS_MAX).map(a => {
365 const desc = oneLine(a.description ?? '')
366 return `${AGENT_LINE} ${oneLine(a.name || a.type)} [${a.id}] ${a.status}${desc ? ` — ${desc.slice(0, AGENT_DESC_MAX)}` : ''}`
367 })
368 if (live.length > AGENTS_MAX) lines.push(`${AGENT_LINE} +${live.length - AGENTS_MAX} more, not listed`)
369 return lines
370 } catch {
371 return []
372 } finally {
373 ;(timer as { cancel: () => void } | null)?.cancel()
374 }
375}
376const uniq = (lines: string[]) => [...new Set(lines)]
377
378export type AssembleInput = {
379 stream: string
380 saved: string
381 sid: string
382 prev: string | null
383 entries: Entry[]
384 fork: ForkFields
385 // In-flight agent lines (agentLines()), written by code; rendered first in in_progress and never cut.
386 agents?: string[]
387}
388export type Assembled =
389 | { kind: 'ok'; state: string; cursor: string; cut: string[]; degraded: boolean }
390 | { kind: 'blocked'; reason: string }
391
392// The core rule: the fork designated ids and wrote its own fields; every trailer-sourced line is copied by code.
393export function assemble(input: AssembleInput): Assembled {
394 const prev = input.prev ? parseState(input.prev) : null
395 const { fields, answered, done, learned, reversed, superseded } = input.fork
396
397 const lists: Record<string, string[]> = {}
398 for (const sec of LISTS) lists[sec] = []
399
400 // 1. carried id'd lines of the previous state + new trailer clauses, routed by clause type.
401 const carried: Record<string, string[]> = {}
402 for (const sec of LISTS) carried[sec] = (prev?.lists[sec] ?? []).filter(l => idOf(l) !== null)
403 const routed: Record<string, string[]> = {}
404 for (const sec of LISTS) routed[sec] = []
405 for (const entry of input.entries) {
406 for (const clause of parseTrailer(entry.trailer)) {
407 const sec = ROUTE[clause.type]
408 if (sec) routed[sec].push(`${clause.text} (${entry.id})`)
409 }
410 }
411
412 // 2. retire: answered open/assumption items, done next items and obsolete learnings vanish; reversed or superseded
413 // decisions move to discarded with their id.
414 const movedToDiscarded: string[] = []
415 for (const sec of LISTS) {
416 lists[sec] = uniq([...carried[sec], ...routed[sec]]).filter(line => {
417 const id = idOf(line)
418 if (id === null) return true
419 if ((sec === 'next' || sec === 'unknowns') && answered.has(id)) return false
420 if (sec === 'next' && done.has(id)) return false
421 if (sec === 'learnings' && learned.has(id)) return false
422 if (sec === 'decisions' && (reversed.has(id) || superseded.has(id))) {
423 movedToDiscarded.push(`${stripId(line)} — ${reversed.has(id) ? 'reversed' : 'superseded'} (c${pad(id)})`)
424 return false
425 }
426 return true
427 })
428 }
429
430 // 3. fork-owned lines (id'd lines are code-owned and dropped).
431 for (const sec of LISTS) {
432 let own = (fields.lists[sec] ?? []).map(oneLine).filter(l => l !== '' && idOf(l) === null)
433 if (sec === 'decisions') own = own.map(l => (l.includes('—') ? l : `${l} — why missing`))
434 if (sec === 'in_progress') own = own.filter(l => !l.startsWith(AGENT_LINE) && l !== AGENTS_UNKNOWN) // code writes those, never the fork
435 if (sec === 'read_first' || sec === 'in_progress' || sec === 'stale') lists[sec] = own
436 else lists[sec] = uniq([...lists[sec], ...own])
437 }
438 lists.discarded = uniq([...lists.discarded, ...movedToDiscarded])
439 if (lists.unknowns.length === 0) lists.unknowns = ['nothing recorded — treat every fact as unverified']
440
441 // 4. header.
442 const allNs = input.entries.map(e => e.n)
443 const prevCursor = Number((prev?.scalars.journal_cursor ?? '').replace(/^c/, '')) || 0
444 const cursor = `c${pad(Math.max(prevCursor, ...allNs))}`
445 const prevSaved = (prev?.scalars.saved ?? '').split(/\s+/)
446 const prevWriter = prevSaved[1]
447 const previousSession =
448 prevWriter && prevWriter !== input.sid ? prevWriter : prev?.scalars.previous_session || prev?.scalars.predecessor || 'none'
449 const scalars: Record<string, string> = {
450 stream: input.stream,
451 saved: `${input.saved} ${input.sid}`,
452 status: oneLine(fields.scalars.status || prev?.scalars.status || 'unknown'),
453 previous_session: previousSession,
454 goal: oneLine(fields.scalars.goal || prev?.scalars.goal || 'unknown'),
455 journal: journalPath(input.stream),
456 journal_cursor: cursor,
457 deliverable: oneLine(fields.scalars.deliverable || prev?.scalars.deliverable || 'none'),
458 }
459
460 // The overflow line (set by the last-resort cut) is the last line of stale, outside `lists` so no cut step can drop it.
461 // The agent lines (input.agents, <= 11 short lines) sit the same way in front of in_progress: counted in the cap, never cut.
462 let overflowLine: string | null = null
463 const render = () => {
464 const head = SCALARS_HEADER.map(k => `${k}: ${scalars[k]}`).join('\n')
465 const agents = input.agents ?? []
466 const shown = (k: string) => (k === 'stale' && overflowLine ? [...lists.stale, overflowLine] : k === 'in_progress' ? [...agents, ...lists.in_progress] : lists[k])
467 const body = BODY_ORDER.map(k => (k === 'deliverable' ? `deliverable: ${scalars.deliverable}` : `${k}:${shown(k).map(l => `\n- ${l}`).join('')}`)).join('\n')
468 return `${head}\n\n${body}\n`
469 }
470
471 // 5. hard cap, enforced here whatever the fork wrote. Cut order: read_if_needed (compress), stale, in_progress, discarded,
472 // learnings (each oldest first). Still over: id'd lines (cNN) of every body section but read_first, lowest cNN first, then the
473 // un-id'd lines (the fork's own, the newest). Every dropped id is named by an overflow line in stale (the journal keeps them).
474 // Never cut: header scalars, read_first, deliverable, the agent lines. 'blocked' only when those plus the overflow line exceed the cap alone.
475 const cut: string[] = []
476 let text = render()
477 const over = () => text.length > STATE_MAX
478 for (const sec of ['read_if_needed', 'stale', 'in_progress'] as const) {
479 if (!over()) break
480 if (sec === 'read_if_needed') {
481 lists[sec] = lists[sec].map(l => {
482 if (l.length <= SHRINK_LINE) return l
483 const id = l.match(ID_END)
484 const head = stripId(l).slice(0, SHRINK_LINE - 1) + '…'
485 return id ? `${head} (c${id[1]})` : head
486 })
487 text = render()
488 cut.push('read_if_needed compressed')
489 }
490 let dropped = 0
491 while (over() && lists[sec].length > 0) {
492 lists[sec].shift()
493 dropped++
494 text = render()
495 }
496 if (dropped) cut.push(`${sec} -${dropped}`)
497 }
498 const droppedIds = new Set<number>()
499 let plain = 0
500 if (over()) {
501 const overflow = () =>
502 `overflow: dropped to fit ${STATE_MAX} chars: ${[...droppedIds].sort((a, b) => a - b).map(n => `c${pad(n)}`).join(', ') || 'no journal line'}${plain ? ` + ${plain} unnumbered` : ''} — grep the journal by id`
503 // One line dropped: its id (or the unnumbered count) goes on the overflow line, which is part of the measured text.
504 const drop = (line: string) => {
505 const id = idOf(line)
506 if (id === null) plain++
507 else droppedIds.add(id)
508 overflowLine = overflow()
509 text = render()
510 }
511 overflowLine = overflow()
512 text = render()
513 for (const sec of ['discarded', 'learnings'] as const) {
514 let dropped = 0
515 while (over() && lists[sec].length > 0) {
516 drop(lists[sec].shift() as string)
517 dropped++
518 }
519 if (dropped) cut.push(`${sec} -${dropped}`)
520 }
521 // Oldest id'd line across all remaining body sections (a same id in two sections goes one line at a time).
522 const perSection: Record<string, number> = {}
523 while (over()) {
524 let best: { sec: string; i: number; n: number } | null = null
525 for (const sec of BODY_ORDER) {
526 if (sec === 'deliverable' || sec === 'read_first') continue
527 for (let i = 0; i < lists[sec].length; i++) {
528 const n = idOf(lists[sec][i])
529 if (n !== null && (!best || n < best.n)) best = { sec, i, n }
530 }
531 }
532 if (!best) break
533 const found: { sec: string; i: number } = best
534 drop(lists[found.sec].splice(found.i, 1)[0])
535 perSection[found.sec] = (perSection[found.sec] ?? 0) + 1
536 }
537 // Only un-id'd lines left: the most expendable section first, oldest first inside it.
538 for (const sec of ['stale', 'in_progress', 'discarded', 'learnings', 'read_if_needed', 'next', 'unknowns', 'decisions']) {
539 while (over() && lists[sec].length > 0) {
540 drop(lists[sec].shift() as string)
541 perSection[sec] = (perSection[sec] ?? 0) + 1
542 }
543 }
544 for (const sec of BODY_ORDER) if (perSection[sec]) cut.push(`${sec} -${perSection[sec]}`)
545 }
546 if (over()) return { kind: 'blocked', reason: `state ${text.length} chars > ${STATE_MAX} after cutting ${cut.join(', ') || 'nothing cuttable'}; the header, read_first, the agent lines and the overflow line alone exceed the cap: shorten read_first by hand. Nothing written.` }
547 return { kind: 'ok', state: text, cursor, cut, degraded: overflowLine !== null }
548}
549
550// ---------- journal (all disk access of the mod goes through these three functions) ----------
551
552type Item = { trailer: string; iso: string; sid: string }
553
554// One write = read + size check + write(old + entries). No append in $.fs, so a size change between read and write
555// (another session wrote) re-reads and retries once; still failing -> null and the caller keeps the items in the store.
556async function appendEntries($: EngineInterface, stream: string, items: Item[]): Promise<string[] | null> {
557 const path = journalPath(stream)
558 for (let attempt = 0; attempt < 2; attempt++) {
559 try {
560 const before = await $.fs.stat(path).catch(() => null)
561 const old = before ? await $.fs.read(path) : ''
562 const after = await $.fs.stat(path).catch(() => null)
563 if ((before?.size ?? -1) !== (after?.size ?? -1)) continue
564 let n = maxId(old)
565 const ids: string[] = []
566 let add = ''
567 for (const item of items) {
568 n++
569 const id = `c${pad(n)}`
570 ids.push(id)
571 add += `### ${id} ${item.iso} ${item.sid}\n${item.trailer}\n\n`
572 }
573 await $.fs.write(path, `${old}${old === '' || old.endsWith('\n') ? '' : '\n'}${add}`)
574 return ids
575 } catch {
576 // retry once, then give up
577 }
578 }
579 return null
580}
581
582async function readOptional($: EngineInterface, path: string): Promise<string | null> {
583 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
584}
585
586function enqueue<T>(job: () => Promise<T>): Promise<T> {
587 const run = journalQueue.then(job, job)
588 journalQueue = run.catch(() => undefined)
589 return run
590}
591
592// ---------- stream binding + capture ----------
593
594const current = () => boundArg ?? titleStream
595
596// Buffered items (no stream yet, or a failed append) + new ones: appended in order once a stream is bound.
597async function ingest($: EngineInterface, fresh: Item[]) {
598 const sid = await $.session.id()
599 const key = bufferKey(sid)
600 const buffered = ((await $.store.get(key)) as Item[] | undefined) ?? []
601 const items = [...buffered, ...fresh]
602 if (items.length === 0) return
603 const stream = current()
604 if (!stream) {
605 await $.store.set(key, items)
606 setWaiting($, { count: items.length, stream: null })
607 return
608 }
609 const ids = await enqueue(() => appendEntries($, stream, items))
610 if (ids) {
611 if (buffered.length > 0) await $.store.delete(key)
612 setWaiting($, null)
613 } else {
614 await $.store.set(key, items)
615 setWaiting($, { count: items.length, stream })
616 $.ui.toast(`Could not write the journal of stream "${stream}"; ${items.length} ${items.length === 1 ? 'entry' : 'entries'} kept for later`, { timeoutMs: ERROR_TOAST_MS })
617 }
618}
619
620async function bind($: EngineInterface, apply: () => void) {
621 const before = current()
622 apply()
623 if (!before && current()) await ingest($, [])
624}
625
626async function noteTitle($: EngineInterface, title: string | undefined) {
627 if (title === undefined) return
628 const slug = slugify(title)
629 if (slug === null) return
630 await bind($, () => {
631 titleStream = slug
632 })
633}
634
635// ---------- on-screen trailer hiding (drawing only; the stored message and the journal capture keep the trailer) ----------
636
637const TRAILER_OPEN = '<!-- ckpt'
638
639// Closed trailers cut out of one AssistantMessage block's text, the text kept byte for byte up to them.
640// `unclosed`: a trailer is still streaming (no `-->` yet); its tail is cut too.
641export function splitTrailer(text: string): { body: string; trailers: string[]; kinds: string[]; unclosed: boolean } {
642 const kinds: string[] = []
643 const trailers: string[] = []
644 let body = text.replace(/\s*<!-- ckpt[\s\S]*?-->/g, raw => {
645 const trailer = raw.trim()
646 trailers.push(trailer)
647 for (const c of parseTrailer(trailer)) if (!kinds.includes(c.type)) kinds.push(c.type)
648 return ''
649 })
650 const open = body.indexOf(TRAILER_OPEN)
651 const unclosed = open >= 0
652 if (unclosed) body = body.slice(0, open)
653 return { body: body.trimEnd(), trailers, kinds, unclosed }
654}
655
656const clauseList = (kinds: string[]) => (kinds.length ? `: ${kinds.join(', ')}` : '')
657
658// Pure text rewrite: closed trailers and an unclosed streaming tail are cut; a closed one leaves one italic marker naming the clauses.
659// Used while a trailer streams (no marker) and as the fallback when the tree cannot be drawn (text too long).
660export function hideTrailer(text: string): string {
661 if (!text.includes(TRAILER_OPEN)) return text
662 const { body, kinds, unclosed } = splitTrailer(text)
663 if (unclosed) return body
664 const marker = `_ckpt${clauseList(kinds)}_`
665 return body ? `${body}\n\n${marker}` : marker
666}
667
668// Message ids (e.requestId) whose trailer is expanded on screen. Module scope: a press flips it and redraws; lost on mod reload (all collapsed again).
669const expanded = new Set<string>()
670
671// A seam = the answer holds a ckpt trailer with a `decision` clause.
672export function hasDecisionSeam(answer: string): boolean {
673 const trailers = answer.match(/<!-- ckpt[\s\S]*?-->/g)
674 return !!trailers && trailers.some(t => parseTrailer(t).some(c => c.type === 'decision'))
675}
676
677// Seam strength of the answer, by code from its ckpt trailers. strong = a `pivot` clause, or a clause naming a task id T<n>
678// together with done / closed / closes / a check mark. simple = a `decision` clause. A closed task wins over a pivot (more specific message).
679export type Seam = { level: 'none' } | { level: 'simple' } | { level: 'strong'; task?: string }
680
681const DONE_RE = /\b(?:done|clos(?:e|es|ed|ing))\b|\u2705/gi
682
683// The task id nearest to a done word in one clause text, or null.
684function closedTask(text: string): string | null {
685 const ids = [...text.matchAll(/\bT(\d+)\b/g)]
686 const dones = [...text.matchAll(DONE_RE)]
687 if (ids.length === 0 || dones.length === 0) return null
688 let best: { n: string; d: number } | null = null
689 for (const id of ids) for (const done of dones) {
690 const d = Math.abs((id.index ?? 0) - (done.index ?? 0))
691 if (!best || d < best.d) best = { n: id[1] ?? '', d }
692 }
693 return best ? `T${best.n}` : null
694}
695
696export function seamOf(answer: string): Seam {
697 const trailers = answer.match(/<!-- ckpt[\s\S]*?-->/g) ?? []
698 let pivot = false
699 let simple = false
700 let task: string | null = null
701 for (const t of trailers) {
702 for (const c of parseTrailer(t)) {
703 if (c.type === 'pivot') pivot = true
704 if (c.type === 'decision') simple = true
705 task = closedTask(c.text) ?? task
706 }
707 }
708 if (task) return { level: 'strong', task }
709 if (pivot) return { level: 'strong' }
710 return simple ? { level: 'simple' } : { level: 'none' }
711}
712
713async function capture($: EngineInterface, answer: string) {
714 const trailers = answer.match(/<!-- ckpt[\s\S]*?-->/g)
715 if (!trailers) return
716 const iso = new Date(await $.clock.now()).toISOString()
717 const sid = await $.session.id()
718 await ingest($, trailers.map(trailer => ({ trailer, iso, sid })))
719}
720
721// ---------- state build (fork + assembly + write) ----------
722
723type Built =
724 | { kind: 'ok'; state: string; cursor: string; cut: string[]; degraded: boolean; migrated: boolean }
725 | { kind: 'blocked'; reason: string }
726
727function forkPrompt(prev: string | null, entries: Entry[]): string {
728 const news = entries.length ? entries.map(e => `### ${e.id}\n${e.trailer}`).join('\n\n') : '(none)'
729 const n = (v: number) => v.toLocaleString('en-US')
730 // The ceiling is enforced by code (assemble); the fork is told the room so it can retire ids before code has to cut.
731 const room = STATE_MAX - (prev?.length ?? 0)
732 const budget = `SIZE: the state file may not exceed ${n(STATE_MAX)} characters; past it code drops the oldest (cNN) lines. PREVIOUS STATE is ${n(prev?.length ?? 0)} characters: ${room >= 0 ? `${n(room)} left` : `${n(-room)} over`} for what the new entries and your lines add.${room < ROOM_TIGHT ? ' Room is tight: designate retire_* ids first (obsolete learnings, done next items, superseded decisions), then shorten the lines you own.' : ''}`
733 return `${FORK_PROMPT}\n\n${budget}\n\nPREVIOUS STATE:\n${prev ?? '(none)'}\n\nJOURNAL ENTRIES TO ROUTE (after the cursor):\n${news}`
734}
735
736type Asked = { kind: 'ok'; fork: ForkFields } | { kind: 'blocked'; reason: string }
737
738async function askFork($: EngineInterface, prompt: string): Promise<Asked> {
739 const reply = await $.model.fork({ prompt })
740 if (!reply.isAnswered) return { kind: 'blocked', reason: `fork failed (${reply.reason})` }
741 const parsed = parseFork(reply.text)
742 if (parsed.kind === 'blocked') return parsed
743 const fork = parseForkBody(parsed.body)
744 return fork ? { kind: 'ok', fork } : { kind: 'blocked', reason: 'unparseable fork output' }
745}
746
747async function assembleAndWrite($: EngineInterface, stream: string): Promise<Built> {
748 try {
749 const sid = await $.session.id()
750 let prev = await readOptional($, statePath(stream))
751 let migrated = false
752 if (prev === null) {
753 prev = await readOptional($, legacyStatePath(stream))
754 migrated = prev !== null
755 }
756 const journal = await readOptional($, journalPath(stream))
757 const cursor = prev ? Number((parseState(prev).scalars.journal_cursor ?? '').replace(/^c/, '')) || 0 : 0
758 const entries = parseJournal(journal ?? '').filter(e => e.n > cursor)
759
760 // Listed before the fork starts: agent.list includes forked skills, so the save fork is not yet there to filter out.
761 const agents = await agentLines($)
762 // One fork per save: an over-cap answer is cut by assemble(), never re-asked.
763 const first = await askFork($, forkPrompt(prev, entries))
764 if (first.kind === 'blocked') return first
765 const result = assemble({ stream, saved: new Date(await $.clock.now()).toISOString(), sid, prev, entries, fork: first.fork, agents })
766 if (result.kind === 'blocked') return result
767 await $.fs.write(statePath(stream), result.state)
768 return { ...result, migrated }
769 } catch (err) {
770 return { kind: 'blocked', reason: `error: ${String(err)}` }
771 }
772}
773
774// What a save did beyond writing the state, for the toast and the {text} replies.
775// `brief`: the toast and the status line leave out the cut details (kept in the status file and the {text} replies).
776function savedNotes(stream: string, built: Extract<Built, { kind: 'ok' }>, brief = false): string {
777 const notes = [`${built.state.length.toLocaleString('en-US')} chars`]
778 if (built.migrated) notes.push(`migrated from ${legacyStatePath(stream)}`)
779 if (built.cut.length && !brief) notes.push(`${built.degraded ? 'over the cap, dropped' : 'trimmed'}: ${built.cut.join(', ')}`)
780 return notes.join(', ')
781}
782
783async function buildState($: EngineInterface, stream: string): Promise<Built> {
784 const built = await assembleAndWrite($, stream)
785 try {
786 const at = new Date(await $.clock.now()).toISOString()
787 const outcome = built.kind === 'blocked' ? 'blocked' : built.degraded ? 'degraded' : 'ok'
788 const detail = built.kind === 'blocked' ? `reason: ${built.reason}` : `detail: ${savedNotes(stream, built)}`
789 await $.fs.write(statusPath(stream), `stream: ${stream}\nat: ${at}\noutcome: ${outcome}\n${detail}\n`)
790 } catch (err) {
791 $.ui.log(`pack: status file not written: ${String(err)}`)
792 }
793 return built
794}
795
796// `/pack save`: the command already returned; the outcome is a toast.
797// `auto` = the hard zone (T19): the outcome becomes the persistent advice line + a toast, and the zone flags are not re-armed
798// (the fill is still high: re-arming would save again at every measure).
799async function saveInBackground($: EngineInterface, stream: string, auto?: { pct: number; cycle: number }) {
800 let failure: string | null = null
801 try {
802 const built = await buildState($, stream)
803 if (auto) {
804 failure = built.kind === 'ok' ? null : built.reason
805 } else if (built.kind === 'ok') {
806 const line = await savedLine($, stream, built)
807 // The toast is the event, the status line the state (with the time, so a later glance tells which save it was).
808 if (line.sticky) $.ui.toast(line.text, { timeoutMs: ERROR_TOAST_MS })
809 else $.ui.toast(`Stream "${stream}" saved (${savedNotes(stream, built, true)})`)
810 outcome = line
811 } else {
812 $.ui.toast(`Could not save stream "${stream}": ${built.reason} (also in ${statusPath(stream)})`, { timeoutMs: ERROR_TOAST_MS })
813 outcome = failedLine(stream, built.reason)
814 }
815 } catch (err) {
816 if (auto) failure = `error: ${String(err)}`
817 else {
818 $.ui.toast(`Saving stream "${stream}" failed: ${String(err)}. Try /pack save again`, { timeoutMs: ERROR_TOAST_MS })
819 outcome = failedLine(stream, `error: ${String(err)}`)
820 }
821 } finally {
822 saving = false
823 if (!auto) rearmZones()
824 if (auto) hardOutcome($, auto, failure)
825 else flowStatus($, undefined)
826 // A main turn ended during this save: the auto pack it asked for starts now, on a fresh save.
827 if (autoDue) autoTrigger($).catch(() => undefined)
828 }
829}
830
831const hhmm = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
832
833// The outcome line of a save: ok (goes after the next turn) or, over the cap, a sticky warning.
834async function savedLine($: EngineInterface, stream: string, built: Extract<Built, { kind: 'ok' }>): Promise<Outcome> {
835 if (built.degraded) {
836 return { text: `\u26A0 Stream "${stream}" saved over the size cap: old entries dropped, see ${statusPath(stream)}`, sticky: true, prompted: false }
837 }
838 const at = hhmm(await $.clock.now())
839 return { text: `\u2713 Stream "${stream}" saved at ${at} (${savedNotes(stream, built, true)})`, sticky: false, prompted: false }
840}
841
842const failedLine = (stream: string, reason: string): Outcome => ({
843 text: `\u26A0 Could not save stream "${stream}": ${shortReason(reason)} (see ${statusPath(stream)})`,
844 sticky: true,
845 prompted: false,
846})
847
848const keptText = (stream: string | null | undefined) =>
849 stream ? `staying in this session; stream "${stream}" stays saved in ${statePath(stream)}` : 'staying in this session; the saved stream stays on disk'
850
851// ---------- context zones (T19, T21) ----------
852
853// Re-arm: the toast may fire again, the advice line goes (callers repaint).
854function rearmZones() {
855 softDone = false
856 hardDone = false
857 advice = null
858 autoTries = 0
859 hardDeferred = false
860 cycle++
861}
862
863// A gating hook that failed: logged for diagnosis; its `.catch` then passes the event on unchanged (a stoa failure never blocks the user).
864function noteCaught($: EngineInterface, hook: string, error: { kind: string; message?: string }) {
865 try {
866 $.ui.log(`pack: ${hook} hook ${error.kind}${error.message ? `: ${error.message}` : ''}`, { to: 'debug' })
867 } catch {
868 // nothing left to do
869 }
870}
871
872// Full session reset (/clear, new/resumed/forked session): flags, seam, last fill and the cached wall.
873function resetZones() {
874 rearmZones()
875 seam = { level: 'none' }
876 lastM = null
877 wallCache = null
878}
879
880// ---------- the one status line ----------
881// A plugin has ONE `$.ui.status`. Precedence: an active save/pack flow (flowLine) wins; when it clears, the advice line
882// (if still valid) is drawn again. Every status of the mod goes through flowStatus() / paint(), never `$.ui.status` directly.
883
884// Precedence: the active flow, the last outcome, a failed journal write, the advice, then checkpoints waiting for a stream.
885function paint($: EngineInterface) {
886 const journalFailed = waiting && waiting.stream !== null ? waitingText(waiting) : undefined
887 const noStream = waiting && waiting.stream === null ? waitingText(waiting) : undefined
888 const main = flowLine ?? outcome?.text ?? journalFailed ?? (advice ? adviceText(advice) : undefined) ?? noStream
889 // Auto mode is a state, not a message: a segment after whatever the line says, alone when the line is empty.
890 $.ui.status(autoOn ? (main ? `${main} \u00B7 auto` : 'auto') : main)
891}
892
893// ---------- auto mode state ----------
894
895const autoPath = async ($: EngineInterface) => `${await $.session.cwd()}/${AUTO_FILE}`
896
897// Reads the toggle file; any failure counts as off. Repaints when the state moved.
898async function refreshAuto($: EngineInterface) {
899 const before = autoOn
900 try {
901 autoOn = await $.fs.exists(await autoPath($))
902 } catch {
903 autoOn = false
904 }
905 if (autoOn !== before) paint($)
906}
907
908async function autoToggle($: EngineInterface, off: boolean) {
909 const path = await autoPath($)
910 try {
911 if (off) {
912 const r = await $.process.run(['rm', '-f', path])
913 if (r.exitCode !== 0) return { text: `pack: could not remove ${AUTO_FILE}: ${shortReason(r.stderr || `exit ${r.exitCode}`)}. Auto mode unchanged.` }
914 } else {
915 await $.fs.write(path, 'pack auto mode: delete this file or type /pack auto off\n')
916 }
917 } catch (err) {
918 return { text: `pack: could not ${off ? 'remove' : 'create'} ${AUTO_FILE}: ${shortReason(String(err))}. Auto mode unchanged.` }
919 }
920 await refreshAuto($)
921 if (off && !autoOn) {
922 autoDue = false
923 // The hard save was deferred to a turn end that will not auto-pack any more: the next measure runs it as before.
924 if (hardDeferred) hardDone = false
925 hardDeferred = false
926 }
927 if (off) return { text: autoOn ? `pack: ${AUTO_FILE} is still there, auto mode stays on` : 'pack: auto mode off' }
928 const stream = current()
929 const cwd = await $.session.cwd()
930 return {
931 text: stream
932 ? `pack: auto mode on for ${cwd} (stream "${stream}")`
933 : `pack: auto mode on for ${cwd} (no stream yet: auto will only advise until /pack save <name> or /rename)`,
934 }
935}
936
937function flowStatus($: EngineInterface, text: string | undefined) {
938 stopProgress()
939 flowLine = text
940 paint($)
941}
942
943export const elapsed = (ms: number) => {
944 const s = Math.floor(ms / 1000)
945 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${pad(s % 60)}s`
946}
947
948export function progressText(label: string, ms: number): string {
949 if (ms >= SLOW_MS) return `${label}: still running after ${elapsed(ms)}, slow or stuck; the outcome will show here`
950 return ms < 1000 ? `${label}...` : `${label}... ${elapsed(ms)}`
951}
952
953// A flow line with its elapsed time, ticking every second until the next flowStatus(). `label` is read at each tick.
954function startProgress($: EngineInterface, label: () => string) {
955 flowStatus($, progressText(label(), 0))
956 let ms = 0
957 ticker = $.clock.every(1000, () => {
958 ms += 1000
959 flowLine = progressText(label(), ms)
960 paint($)
961 })
962}
963
964function stopProgress() {
965 ticker?.cancel()
966 ticker = null
967}
968
969export function waitingText(w: Waiting): string {
970 const n = `${w.count} checkpoint${w.count === 1 ? '' : 's'}`
971 return w.stream === null
972 ? `${n} waiting for a stream: /rename the session or type /pack save <name>`
973 : `\u26A0 Journal of stream "${w.stream}" not written: ${n} kept, retried at the next checkpoint`
974}
975
976function setWaiting($: EngineInterface, next: Waiting | null) {
977 if (next === null && waiting === null) return
978 if (next && waiting && next.count === waiting.count && next.stream === waiting.stream) return
979 waiting = next
980 paint($)
981}
982
983// An ok outcome goes at the end of the first turn prompted after it.
984function outcomeTurnDone($: EngineInterface) {
985 if (outcome && !outcome.sticky && outcome.prompted) {
986 outcome = null
987 paint($)
988 }
989}
990
991// ---------- advice wording (plain words, each says what happened and what to type) ----------
992
993export type Advice = {
994 kind: 'task' | 'pivot' | 'decision' | 'saved' | 'failed' | 'nostream'
995 // 1 simple seam, 2 strong seam, 3 hard zone: a higher rank replaces a lower one, never the other way round.
996 rank: 1 | 2 | 3
997 // The advice is dropped when the fill falls under this.
998 floor: number
999 pct: number
1000 task?: string
1001 reason?: string
1002}
1003
1004const SHORT_REASON = 80
1005
1006const shortReason = (reason: string) => {
1007 const one = reason.replace(/\s+/g, ' ').trim()
1008 return one.length > SHORT_REASON ? `${one.slice(0, SHORT_REASON - 1)}…` : one
1009}
1010
1011export function adviceText(a: Advice): string {
1012 switch (a.kind) {
1013 case 'task':
1014 return `Good moment for a fresh start: ${a.task} just closed (context ${a.pct}%). Type /pack`
1015 case 'pivot':
1016 return `Good moment for a fresh start: the direction just changed (context ${a.pct}%). Type /pack`
1017 case 'decision':
1018 return `Good moment for a fresh start: a decision just landed (context ${a.pct}%). Type /pack`
1019 case 'saved':
1020 return `Progress saved automatically (context ${a.pct}%). Type /pack to continue in a fresh session`
1021 case 'failed':
1022 return `Context almost full (${a.pct}%) and the automatic save failed (${shortReason(a.reason ?? 'unknown')}). Type /pack to continue in a fresh session`
1023 case 'nostream':
1024 return `Context almost full (${a.pct}%) but no stream is set, so nothing was saved. Type /pack save <name>`
1025 }
1026}
1027
1028const savingText = (pct: number) => `Context almost full (${pct}%): automatically saving your progress`
1029
1030const seamName = (s: Seam) => (s.level === 'strong' ? `strong:${s.task ?? 'pivot'}` : s.level)
1031
1032// fill = tokens / window; pct = the figure shown (status-line percent when present, else the rounded fill); hardAt = tokens at which the hard zone starts.
1033type Measure = { fill: number; pct: number; tokens: number; window: number; wall: number | null; hardAt: number }
1034
1035// One line per measure, silent ones included, to the debug log (nothing on screen).
1036function ctxLog($: EngineInterface, src: 'measure' | 'turn', m: Measure, action: string) {
1037 $.ui.log(
1038 `pack: ctx pct=${m.pct}% tokens=${m.tokens} window=${m.window} wall=${m.wall ?? 'none'} hard=${m.hardAt} seam=${seamName(seam)} action=${action} src=${src}`,
1039 { to: 'debug' },
1040 )
1041}
1042
1043// The wall = the token count where auto-compaction fires: auto-compact threshold, else the compaction window, else unknown (null).
1044async function wallOf($: EngineInterface): Promise<number | null> {
1045 if (wallCache !== null) return wallCache
1046 try {
1047 const breakdown = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
1048 const wall = breakdown?.autoCompactThreshold ?? breakdown?.rawMaxTokens
1049 if (wall !== undefined && wall > 0) {
1050 wallCache = wall
1051 return wall
1052 }
1053 } catch (err) {
1054 $.ui.log(`pack: ctx usage unavailable, hard = HARD * window: ${String(err)}`, { to: 'debug' })
1055 }
1056 return null
1057}
1058
1059// Tokens at which the hard pre-save starts: HARD * window, capped at WALL_CAP * wall when the wall is known.
1060export function hardAtOf(window: number, wall: number | null): number {
1061 const byWindow = HARD * window
1062 return Math.round(wall === null ? byWindow : Math.min(byWindow, WALL_CAP * wall))
1063}
1064
1065// Seam advice. Runs from session.measure (fill moved) and from turn.complete (seam moved), so the order of the two events does not matter.
1066// strong seam + fill >= STRONG, or simple seam + fill >= SOFT: the advice line is drawn and the toast shown once per cycle.
1067// A stronger seam later upgrades the line without a second toast; the percentage on the line follows the fill.
1068// Returns the action for the log line.
1069function adviceStep($: EngineInterface, fill: number, pct: number): string {
1070 if (phase === 'armed') return 'skip-armed'
1071 const eligible = seam.level === 'strong' ? fill >= STRONG : seam.level === 'simple' ? fill >= SOFT : false
1072 if (!eligible) {
1073 if (!advice) return seam.level === 'none' ? 'no-seam' : 'below-floor'
1074 advice.pct = pct
1075 paint($)
1076 return 'advice-keep'
1077 }
1078 const next: Advice =
1079 seam.level === 'strong'
1080 ? seam.task
1081 ? { kind: 'task', rank: 2, floor: STRONG, pct, task: seam.task }
1082 : { kind: 'pivot', rank: 2, floor: STRONG, pct }
1083 : { kind: 'decision', rank: 1, floor: SOFT, pct }
1084 if (!advice) {
1085 advice = next
1086 if (outcome && !outcome.sticky) outcome = null
1087 paint($)
1088 if (!softDone) {
1089 softDone = true
1090 $.ui.toast(adviceText(next))
1091 }
1092 return 'advice'
1093 }
1094 if (next.rank > advice.rank) {
1095 advice = next
1096 if (outcome && !outcome.sticky) outcome = null
1097 paint($)
1098 return 'advice-upgrade'
1099 }
1100 advice.pct = pct
1101 paint($)
1102 return 'advice-keep'
1103}
1104
1105// Hard: the existing save path, once per cycle, never a clear, never an arm. Returns the action for the log line.
1106function hardSave($: EngineInterface, pct: number): string {
1107 if (hardDone) {
1108 if (advice) {
1109 advice.pct = pct
1110 paint($)
1111 }
1112 return 'hard-done'
1113 }
1114 hardDone = true
1115 softDone = true
1116 const stream = current()
1117 if (!stream) {
1118 advice = { kind: 'nostream', rank: 3, floor: SOFT, pct }
1119 paint($)
1120 $.ui.toast(adviceText(advice))
1121 return 'hard-nostream'
1122 }
1123 if (saving || phase !== 'idle') return saving ? 'hard-skip-saving' : `hard-skip-${phase}`
1124 // Auto mode: the save is the auto pack at the turn end (a fork now would save before the end of the turn, and the turn is still running).
1125 // Exception: the wall is close (compaction imminent), a mid-turn save beats none. Not once the turn is over: the auto pack runs from this measure.
1126 if (autoOn && (turnOver || !(lastM?.wall != null && lastM.tokens >= WALL_CAP * lastM.wall))) {
1127 hardDeferred = true
1128 return 'hard-auto-deferred'
1129 }
1130 saving = true
1131 startProgress($, () => savingText(pct))
1132 saveInBackground($, stream, { pct, cycle }).catch(() => {
1133 saving = false
1134 })
1135 return 'hard-save'
1136}
1137
1138// The hard save finished: its outcome is the advice line (the flow line goes) and one toast. Stale (re-armed meanwhile): log only.
1139function hardOutcome($: EngineInterface, auto: { pct: number; cycle: number }, failure: string | null) {
1140 stopProgress()
1141 flowLine = undefined
1142 // Newer than any ok outcome still shown.
1143 if (outcome && !outcome.sticky) outcome = null
1144 if (auto.cycle !== cycle) {
1145 paint($)
1146 return
1147 }
1148 const pct = lastM ? lastM.pct : auto.pct
1149 advice = failure === null ? { kind: 'saved', rank: 3, floor: SOFT, pct } : { kind: 'failed', rank: 3, floor: SOFT, pct, reason: failure }
1150 paint($)
1151 $.ui.toast(adviceText(advice), failure === null ? undefined : { timeoutMs: ERROR_TOAST_MS })
1152}
1153
1154async function onMeasure($: EngineInterface, tokens: number, window: number, percent: number | undefined) {
1155 const fill = tokens / window
1156 const pct = percent !== undefined ? Math.round(percent) : Math.round(fill * 100)
1157 // The wall only matters for the hard cap: not asked far below the window, then cached.
1158 const wall = wallCache !== null || fill >= USAGE_FROM ? await wallOf($) : null
1159 const hardAt = hardAtOf(window, wall)
1160 const m: Measure = { fill, pct, tokens, window, wall, hardAt }
1161 lastM = m
1162 let action: string
1163 // The floor the current advice holds to: its own, or after the hard zone the lower of SOFT and 90% of where it started
1164 // (a low cap must not re-arm at once and save again), else the lowest one.
1165 const floor = hardDone ? Math.min(SOFT, (0.9 * hardAt) / window) : (advice?.floor ?? STRONG)
1166 if ((advice || softDone || hardDone) && fill < floor) {
1167 rearmZones()
1168 paint($)
1169 action = 'rearm'
1170 } else if (tokens >= hardAt) action = hardSave($, pct)
1171 else action = adviceStep($, fill, pct)
1172 // The measure of the turn that just ended (it follows turn.complete): the turn-end trigger saw the turn before's fill, so it runs again on this one.
1173 if (turnOver && tokens >= hardAt) {
1174 const auto = await autoTrigger($)
1175 if (auto !== 'auto-off') action = `${action}+${auto}`
1176 }
1177 ctxLog($, 'measure', m, action)
1178}
1179
1180// ---------- auto pack (turn end) ----------
1181
1182// The human stayed (a prompt, /pack cancel): no clear. The save stays on disk; auto sleeps until the fill falls back under the floor.
1183function autoStay($: EngineInterface, pct: number) {
1184 autoRun = null
1185 autoTries = AUTO_TRIES
1186 hardDone = true
1187 softDone = true
1188 advice = { kind: 'saved', rank: 3, floor: SOFT, pct }
1189 paint($)
1190}
1191
1192// A 1 s countdown on the flow line, until the next flowStatus().
1193function startCountdown($: EngineInterface, label: (left: number) => string, seconds: number) {
1194 flowStatus($, label(seconds))
1195 let left = seconds
1196 ticker = $.clock.every(1000, () => {
1197 left = Math.max(left - 1, 0)
1198 flowLine = label(left)
1199 paint($)
1200 })hooks/keel-core.ts 287 lines1// keel core: pure functions, no `$`, no I/O. Spec: SPEC-keel-llm.md §3 (declaration format) and §5 (hashing).
2
3export const STAMP_LEN = 12
4// D13: only H2/H3 headings are valid anchors on the source side.
5export const ANCHOR_LEVELS: readonly number[] = [2, 3]
6
7// ── hashing (D6) ────────────────────────────────────────────────────────────
8
9const ENC = new TextEncoder()
10
11export function sha1Hex(bytes: Uint8Array): string {
12 const h = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]
13 const len = bytes.length
14 const padded = new Uint8Array((((len + 8) >> 6) << 6) + 64)
15 padded.set(bytes)
16 padded[len] = 0x80
17 const dv = new DataView(padded.buffer)
18 dv.setUint32(padded.length - 4, (len * 8) >>> 0)
19 dv.setUint32(padded.length - 8, Math.floor((len * 8) / 4294967296))
20 const w = new Uint32Array(80)
21 const rol = (x: number, n: number) => (x << n) | (x >>> (32 - n))
22 for (let o = 0; o < padded.length; o += 64) {
23 for (let i = 0; i < 16; i++) w[i] = dv.getUint32(o + i * 4)
24 for (let i = 16; i < 80; i++) w[i] = rol(w[i - 3]! ^ w[i - 8]! ^ w[i - 14]! ^ w[i - 16]!, 1)
25 let a = h[0]!, b = h[1]!, c = h[2]!, d = h[3]!, e = h[4]!
26 for (let i = 0; i < 80; i++) {
27 const f = i < 20 ? (b & c) | (~b & d) : i < 40 ? b ^ c ^ d : i < 60 ? (b & c) | (b & d) | (c & d) : b ^ c ^ d
28 const k = i < 20 ? 0x5a827999 : i < 40 ? 0x6ed9eba1 : i < 60 ? 0x8f1bbcdc : 0xca62c1d6
29 const t = (rol(a, 5) + f + e + k + w[i]!) >>> 0
30 e = d
31 d = c
32 c = rol(b, 30) >>> 0
33 b = a
34 a = t
35 }
36 h[0] = (h[0]! + a) >>> 0
37 h[1] = (h[1]! + b) >>> 0
38 h[2] = (h[2]! + c) >>> 0
39 h[3] = (h[3]! + d) >>> 0
40 h[4] = (h[4]! + e) >>> 0
41 }
42 return h.map(x => x.toString(16).padStart(8, '0')).join('')
43}
44
45// git blob id: sha1("blob " + byteLength + "\0" + content)
46export function blobSha(content: string | Uint8Array): string {
47 const body = typeof content === 'string' ? ENC.encode(content) : content
48 const head = ENC.encode(`blob ${body.length}\0`)
49 const all = new Uint8Array(head.length + body.length)
50 all.set(head)
51 all.set(body, head.length)
52 return sha1Hex(all)
53}
54
55export const stampOf = (content: string | Uint8Array): string => blobSha(content).slice(0, STAMP_LEN)
56
57// ── entry grammar (§3.1) ────────────────────────────────────────────────────
58// entry = ref [ "#" heading ] [ " @" stamp ] stamp = 12 lowercase hex | "ok"
59
60export type StampState = { kind: 'none' } | { kind: 'hex'; value: string } | { kind: 'ok' } | { kind: 'bad'; raw: string }
61// wikilink: `ref` holds the bare name (no brackets); url: kept whole, never split on '#'
62export type RefKind = 'path' | 'wikilink' | 'url'
63export type ParsedEntry = { raw: string; ref: string; kind: RefKind; anchor?: string; stamp: StampState }
64
65const URL_RE = /^[a-z][a-z0-9+.-]*:\/\//i
66const WIKI_RE = /^\[\[([^\]]*)\]\](?:#(.*))?$/
67const STAMP_TAIL = /\s@(\S*)$/
68const HEX_STAMP = new RegExp(`^[0-9a-f]{${STAMP_LEN}}$`)
69
70export function parseEntry(input: string): ParsedEntry | null {
71 const raw = input.trim()
72 let s = raw
73 if (!s) return null
74 let stamp: StampState = { kind: 'none' }
75 const m = STAMP_TAIL.exec(s)
76 if (m) {
77 s = s.slice(0, m.index).trim()
78 const v = m[1]!
79 stamp = v === 'ok' ? { kind: 'ok' } : HEX_STAMP.test(v) ? { kind: 'hex', value: v } : { kind: 'bad', raw: v }
80 }
81 if (!s) return null
82 if (URL_RE.test(s)) return { raw, ref: s, kind: 'url', stamp }
83 const w = WIKI_RE.exec(s)
84 if (w) {
85 const inner = w[1]!.split('|')[0]!
86 const hash = inner.indexOf('#')
87 const name = (hash < 0 ? inner : inner.slice(0, hash)).trim()
88 const anchor = (w[2] ?? (hash < 0 ? '' : inner.slice(hash + 1))).trim()
89 if (!name) return null
90 return anchor ? { raw, ref: name, kind: 'wikilink', anchor, stamp } : { raw, ref: name, kind: 'wikilink', stamp }
91 }
92 const hash = s.indexOf('#')
93 const ref = (hash < 0 ? s : s.slice(0, hash)).trim()
94 const anchor = hash < 0 ? '' : s.slice(hash + 1).trim()
95 if (!ref) return null
96 return anchor ? { raw, ref, kind: 'path', anchor, stamp } : { raw, ref, kind: 'path', stamp }
97}
98
99// Replaces (or appends) the stamp of one raw entry string. `stamp` is 12 hex or "ok".
100export const withStamp = (raw: string, stamp: string): string => `${raw.trim().replace(/\s@\S*$/, '')} @${stamp}`
101
102// ── headings and sections (§3.3) ────────────────────────────────────────────
103
104export type Heading = { level: number; text: string; line: number; start: number }
105
106const HEADING_RE = /^ {0,3}(#{1,6})[ \t]+(.*?)[ \t]*$/
107const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
108
109type Line = { text: string; start: number }
110
111function linesOf(text: string): Line[] {
112 const out: Line[] = []
113 let start = 0
114 for (const t of text.split('\n')) {
115 out.push({ text: t.endsWith('\r') ? t.slice(0, -1) : t, start })
116 start += t.length + 1
117 }
118 return out
119}
120
121// Headings outside fenced code blocks; `start` is the char offset of the heading line.
122export function headings(text: string): Heading[] {
123 return headingsOf(linesOf(text))
124}
125
126function headingsOf(lines: Line[]): Heading[] {
127 const out: Heading[] = []
128 let fence = ''
129 lines.forEach((l, line) => {
130 const f = FENCE_RE.exec(l.text)
131 if (fence) {
132 if (f && f[1]![0] === fence[0] && f[1]!.length >= fence.length && l.text.trim() === f[1]) fence = ''
133 return
134 }
135 if (f) {
136 fence = f[1]!
137 return
138 }
139 const m = HEADING_RE.exec(l.text)
140 if (m && m[2]) out.push({ level: m[1]!.length, text: m[2], line, start: l.start })
141 })
142 return out
143}
144
145// A section runs from its heading line to the next heading of the same or higher level (exclusive).
146function sectionEnd(hs: Heading[], i: number, textLen: number): number {
147 const level = hs[i]!.level
148 for (let j = i + 1; j < hs.length; j++) if (hs[j]!.level <= level) return hs[j]!.start
149 return textLen
150}
151
152export type Section = { heading: string; level: number; start: number; end: number; text: string }
153
154// Source-side anchor lookup: exact, case-sensitive heading text, H2/H3 only, first match wins.
155export function extractSection(text: string, heading: string): Section | null {
156 const want = heading.trimEnd()
157 const hs = headings(text)
158 const i = hs.findIndex(h => ANCHOR_LEVELS.includes(h.level) && h.text === want)
159 if (i < 0) return null
160 const h = hs[i]!
161 const end = sectionEnd(hs, i, text.length)
162 return { heading: h.text, level: h.level, start: h.start, end, text: text.slice(h.start, end) }
163}
164
165// Every anchorable section of a source: heading -> text (first wins on duplicates).
166export function anchorSections(text: string): Map<string, Section> {
167 const hs = headings(text)
168 const out = new Map<string, Section>()
169 hs.forEach((h, i) => {
170 if (!ANCHOR_LEVELS.includes(h.level) || out.has(h.text)) return
171 const end = sectionEnd(hs, i, text.length)
172 out.set(h.text, { heading: h.text, level: h.level, start: h.start, end, text: text.slice(h.start, end) })
173 })
174 return out
175}
176
177// ── declarations in a child (§3.1, §3.2) ────────────────────────────────────
178
179export type Scope = { type: 'file' } | { type: 'section'; heading: string; level: number; start: number; end: number }
180// `line` = 0-based line holding the entry (rewrite hint for stamping); `raw` is the unquoted entry text on that line.
181export type Entry = ParsedEntry & { scope: Scope; line: number }
182
183function unquote(s: string): string {
184 const t = s.trim()
185 if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) return t.slice(1, -1).replace(/\\(["\\])/g, '$1')
186 if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) return t.slice(1, -1).replace(/''/g, "'")
187 return t.replace(/\s+#(\s.*)?$/, '').trim()
188}
189
190// Splits a YAML flow list body on top-level commas, honoring quotes and nested brackets.
191function splitFlow(body: string): string[] {
192 const out: string[] = []
193 let cur = ''
194 let quote = ''
195 let depth = 0
196 for (const ch of body) {
197 if (quote) {
198 if (ch === quote) quote = ''
199 } else if (ch === '"' || ch === "'") quote = ch
200 else if (ch === '[') depth++
201 else if (ch === ']') depth--
202 else if (ch === ',' && depth === 0) {
203 out.push(cur)
204 cur = ''
205 continue
206 }
207 cur += ch
208 }
209 out.push(cur)
210 return out
211}
212
213const KEY_RE = /^sources?[ \t]*:(.*)$/
214const ITEM_RE = /^[ \t]*-(?:[ \t]+(.*))?$/
215const SCALAR_WIKI = /^\[\[[^\]]*\]\](?:#.*?)?(?:\s+@\S*)?$/
216
217// Raw entry strings of the frontmatter `source:` / `sources:` keys, with the line each came from.
218function frontmatterEntries(lines: Line[]): { raw: string; line: number }[] {
219 const first = lines[0]?.text.replace(/^\uFEFF/, '').trimEnd()
220 if (first !== '---') return []
221 let end = -1
222 for (let j = 1; j < lines.length; j++) {
223 const t = lines[j]!.text.trimEnd()
224 if (t === '---' || t === '...') {
225 end = j
226 break
227 }
228 }
229 if (end < 0) return []
230 const out: { raw: string; line: number }[] = []
231 for (let j = 1; j < end; j++) {
232 const k = KEY_RE.exec(lines[j]!.text)
233 if (!k) continue
234 const rest = k[1]!.trim()
235 if (!rest || rest.startsWith('#')) {
236 for (let n = j + 1; n < end; n++) {
237 const t = lines[n]!.text
238 if (!t.trim()) continue
239 const it = ITEM_RE.exec(t)
240 if (!it) break
241 if (it[1]) out.push({ raw: unquote(it[1]), line: n })
242 j = n
243 }
244 } else if (rest.startsWith('[') && !SCALAR_WIKI.test(rest)) {
245 const body = rest.replace(/\]\s*(#.*)?$/, '').slice(1)
246 for (const item of splitFlow(body)) out.push({ raw: unquote(item), line: j })
247 } else out.push({ raw: unquote(rest), line: j })
248 }
249 return out
250}
251
252const SECTION_COMMENT = /^\s*<!--\s*sources?:\s*(.*?)\s*-->\s*$/
253
254// Section-level declarations: an HTML comment on the line right after a heading.
255function sectionEntries(lines: Line[], textLen: number): { raw: string; line: number; scope: Scope }[] {
256 const hs = headingsOf(lines)
257 const out: { raw: string; line: number; scope: Scope }[] = []
258 hs.forEach((h, i) => {
259 const next = lines[h.line + 1]
260 const m = next ? SECTION_COMMENT.exec(next.text) : null
261 if (!m) return
262 const scope: Scope = { type: 'section', heading: h.text, level: h.level, start: h.start, end: sectionEnd(hs, i, textLen) }
263 for (const part of m[1]!.split(';')) if (part.trim()) out.push({ raw: part.trim(), line: h.line + 1, scope })
264 })
265 return out
266}
267
268// All declared edges of a child: frontmatter (source + sources, unioned and deduped by ref+anchor) then section comments.
269export function parseChild(text: string): Entry[] {
270 const lines = linesOf(text)
271 const out: Entry[] = []
272 const seen = new Set<string>()
273 for (const f of frontmatterEntries(lines)) {
274 const p = parseEntry(f.raw)
275 if (!p) continue
276 const key = `${p.kind}|${p.ref}|${p.anchor ?? ''}`
277 if (seen.has(key)) continue
278 seen.add(key)
279 out.push({ ...p, scope: { type: 'file' }, line: f.line })
280 }
281 for (const s of sectionEntries(lines, text.length)) {
282 const p = parseEntry(s.raw)
283 if (p) out.push({ ...p, scope: s.scope, line: s.line })
284 }
285 return out
286}
287hooks/keel-index.ts 528 lines1// keel index: zones, reference resolution, per-entry assessment. Pure: no `$` here (plugin validate follows `$` only inside one file, F-e); the I/O half is in keel.ts.
2// Spec: SPEC-keel-llm.md §3.4, §4, §5.
3
4import { anchorSections, parseChild, STAMP_LEN, withStamp } from './keel-core.ts'
5import type { Entry, RefKind, StampState } from './keel-core.ts'
6
7// ── zones (§4) ──────────────────────────────────────────────────────────────
8
9export type Zones = { ignore: string[]; frozen: string[] }
10export const DEFAULT_ZONES: Zones = { ignore: ['park', 'archive', 'node_modules', '.git', '.tmp', '.dump', 'build'], frozen: ['in', '.in'] }
11export const ZONES_FILE = '.stoa-keel.yml'
12
13function listValue(rest: string): string[] {
14 const body = rest.trim().replace(/^\[/, '').replace(/\]\s*(#.*)?$/, '')
15 return body
16 .split(',')
17 .map(s => s.trim().replace(/^(["'])(.*)\1$/, '$2'))
18 .filter(Boolean)
19}
20
21// Minimal YAML subset: `ignore:` / `frozen:` as a flow list or a block list. A key it does not set keeps the default.
22export function parseZones(yml: string): Zones {
23 const out: Zones = { ignore: [...DEFAULT_ZONES.ignore], frozen: [...DEFAULT_ZONES.frozen] }
24 const lines = yml.split('\n').map(l => l.replace(/\r$/, ''))
25 for (let i = 0; i < lines.length; i++) {
26 const m = /^(ignore|frozen)[ \t]*:(.*)$/.exec(lines[i]!)
27 if (!m) continue
28 const key = m[1] as keyof Zones
29 const rest = m[2]!.trim()
30 if (rest && !rest.startsWith('#')) {
31 out[key] = listValue(rest)
32 continue
33 }
34 const items: string[] = []
35 for (let n = i + 1; n < lines.length; n++) {
36 const it = /^[ \t]*-[ \t]+(.*)$/.exec(lines[n]!)
37 if (!it) {
38 if (lines[n]!.trim()) break
39 continue
40 }
41 items.push(it[1]!.trim().replace(/\s+#.*$/, '').replace(/^(["'])(.*)\1$/, '$2'))
42 i = n
43 }
44 out[key] = items.filter(Boolean)
45 }
46 return out
47}
48
49export const hasSegment = (path: string, names: readonly string[]): boolean => path.split('/').some(s => names.includes(s))
50
51// ── paths ───────────────────────────────────────────────────────────────────
52
53export function normalizePath(path: string): string {
54 const abs = path.startsWith('/')
55 const out: string[] = []
56 for (const seg of path.split('/')) {
57 if (!seg || seg === '.') continue
58 if (seg === '..') {
59 if (out.length && out[out.length - 1] !== '..') out.pop()
60 else if (!abs) out.push('..')
61 } else out.push(seg)
62 }
63 return (abs ? '/' : '') + out.join('/')
64}
65
66// Root-relative when the target is inside the chantier root, absolute otherwise.
67export function toTarget(root: string, ref: string): string {
68 const full = normalizePath(ref.startsWith('/') ? ref : `${root}/${ref}`)
69 return full === root ? '' : full.startsWith(`${root}/`) ? full.slice(root.length + 1) : full
70}
71
72export const absOf = (root: string, target: string): string => (target.startsWith('/') ? target : `${root}/${target}`)
73
74const baseKey = (path: string): string => (path.split('/').pop() ?? path).replace(/\.md$/i, '').toLowerCase()
75
76// Obsidian-style: unique basename (case-insensitive); a name with '/' matches by path suffix. 0 or >1 hits -> null.
77export function resolveWikilink(name: string, mdFiles: readonly string[]): string | null {
78 const want = name.replace(/\.md$/i, '').toLowerCase()
79 const hits = want.includes('/')
80 ? mdFiles.filter(f => f.replace(/\.md$/i, '').toLowerCase().endsWith(`/${want}`) || f.replace(/\.md$/i, '').toLowerCase() === want)
81 : mdFiles.filter(f => baseKey(f) === want)
82 return hits.length === 1 ? hits[0]! : null
83}
84
85// ── index ───────────────────────────────────────────────────────────────────
86
87export type EntryRec = {
88 raw: string
89 ref: string
90 kind: RefKind
91 anchor?: string
92 stamp: StampState
93 scope: 'file' | string // 'file' or the heading of the section holding the declaration
94 // root-relative path, absolute path outside root, or null when opaque (url) or unresolved (wikilink 0 / >1 hit)
95 target: string | null
96 line: number
97}
98export type ChildRec = { mtime: number; size: number; entries: EntryRec[] }
99export type SourceRec = {
100 mtime: number
101 size: number
102 blob: string // full 40 hex git blob id of the whole content
103 sections: Record<string, string> // H2/H3 heading -> blob id of the section text
104 missing?: boolean
105 unreadable?: string // e.g. over the 4 MiB read cap
106}
107export type Index = {
108 v: 1
109 root: string
110 zones: Zones
111 children: Record<string, ChildRec>
112 sources: Record<string, SourceRec>
113 md: string[] // every non-ignored markdown file (wikilink resolution)
114 sweptAt: number
115 degraded: string[]
116 injected?: string[] // suspectKey()s already handed to the model (SessionStart, prompt.submit, PostToolUse): only new ones go out next
117 reviewed?: Record<string, Record<string, string>> // §3.4 `updated`: child -> entryId -> source stamp the agent saw when it wrote the child; consumed at the next boundary
118 turn?: string[] // D10: suspectKey()s alive when the turn began (SessionStart, prompt.submit): a suspect outside it was created this turn
119 blocked?: true // D10: the Stop block was spent this turn; reset when the next turn begins
120 since?: Record<string, number> // suspectKey -> epoch ms it was first seen, for keel-status-llm.md; pruned with the suspects
121 toasted?: string // text of the toast already shown this turn (a Stop re-entry does not repeat it); reset when the next turn begins
122 stamped?: string[] // children keel re-stamped at a boundary and has not told the model about yet (the host reports them as edited)
123}
124
125export const indexKey = (root: string): string => `keel:${root}`
126
127export const emptyIndex = (root: string, zones: Zones = DEFAULT_ZONES): Index => ({ v: 1, root, zones, children: {}, sources: {}, md: [], sweptAt: 0, degraded: [] })
128
129export const isIdle = (ix: Index): boolean => Object.keys(ix.children).length === 0
130
131// children -> sources. Computed, never persisted (D5).
132export function reverseOf(ix: Index): Record<string, string[]> {
133 const out: Record<string, string[]> = {}
134 for (const [child, rec] of Object.entries(ix.children))
135 for (const e of rec.entries) {
136 if (e.target === null) continue
137 const list = (out[e.target] ??= [])
138 if (!list.includes(child)) list.push(child)
139 }
140 return out
141}
142
143function toRec(e: Entry, root: string, md: readonly string[]): EntryRec {
144 const rec: EntryRec = {
145 raw: e.raw,
146 ref: e.ref,
147 kind: e.kind,
148 stamp: e.stamp,
149 scope: e.scope.type === 'file' ? 'file' : e.scope.heading,
150 target: null,
151 line: e.line,
152 }
153 if (e.anchor) rec.anchor = e.anchor
154 if (e.kind === 'path') rec.target = toTarget(root, e.ref)
155 else if (e.kind === 'wikilink') rec.target = resolveWikilink(e.ref, md)
156 return rec
157}
158
159export function buildChild(text: string, mtime: number, size: number, root: string, md: readonly string[]): ChildRec {
160 return { mtime, size, entries: parseChild(text).map(e => toRec(e, root, md)) }
161}
162
163// ── assessment (§3.4), pure ─────────────────────────────────────────────────
164
165export type Reason = 'changed' | 'anchor-missing' | 'source-missing' | 'source-parked' | 'bad-stamp'
166export type EntryState =
167 | { kind: 'clean' }
168 | { kind: 'pending-stamp'; current: string } // no stamp yet: code stamps at the next boundary
169 | { kind: 'ok-requested'; current: string } // `@ok`: code re-stamps at the next boundary
170 | { kind: 'suspect'; reason: Reason; old?: string; current?: string }
171 | { kind: 'opaque' } // url, or any ref that is not a file
172 | { kind: 'unresolved' } // wikilink with 0 or >1 hits: listed, never checked, never suspect
173 | { kind: 'unreadable'; why: string } // source could not be hashed (e.g. over the read cap): never suspect
174
175// The first 12 hex of the blob the entry currently points at, or the reason it points at nothing.
176export function currentStamp(ix: Index, e: EntryRec): { stamp: string } | { reason: Reason } | { unreadable: string } {
177 if (e.target === null) throw new Error('currentStamp needs a resolved target')
178 // zone names match segments of root-relative paths only; an absolute path outside root is never parked
179 if (!e.target.startsWith('/') && hasSegment(e.target, ix.zones.ignore)) return { reason: 'source-parked' }
180 const s = ix.sources[e.target]
181 if (!s || s.missing) return { reason: 'source-missing' }
182 if (s.unreadable) return { unreadable: s.unreadable }
183 if (!e.anchor) return { stamp: s.blob.slice(0, STAMP_LEN) }
184 const sec = s.sections[e.anchor.trimEnd()]
185 return sec ? { stamp: sec.slice(0, STAMP_LEN) } : { reason: 'anchor-missing' }
186}
187
188export function assessEntry(ix: Index, e: EntryRec): EntryState {
189 if (e.kind === 'url') return { kind: 'opaque' }
190 if (e.target === null) return { kind: 'unresolved' }
191 const cur = currentStamp(ix, e)
192 if ('reason' in cur) return { kind: 'suspect', reason: cur.reason }
193 if ('unreadable' in cur) return { kind: 'unreadable', why: cur.unreadable }
194 switch (e.stamp.kind) {
195 case 'none':
196 return { kind: 'pending-stamp', current: cur.stamp }
197 case 'ok':
198 return { kind: 'ok-requested', current: cur.stamp }
199 case 'bad':
200 return { kind: 'suspect', reason: 'bad-stamp', old: e.stamp.raw, current: cur.stamp }
201 case 'hex':
202 return e.stamp.value === cur.stamp ? { kind: 'clean' } : { kind: 'suspect', reason: 'changed', old: e.stamp.value, current: cur.stamp }
203 }
204}
205
206export type Suspect = { child: string; entry: EntryRec; reason: Reason; old?: string; current?: string }
207
208export function suspectsOf(ix: Index): Suspect[] {
209 const out: Suspect[] = []
210 for (const child of Object.keys(ix.children).sort())
211 for (const entry of ix.children[child]!.entries) {
212 const st = assessEntry(ix, entry)
213 if (st.kind === 'suspect') out.push({ child, entry, reason: st.reason, ...(st.old ? { old: st.old } : {}), ...(st.current ? { current: st.current } : {}) })
214 }
215 return out
216}
217
218// ── injection (§6, §12), pure ───────────────────────────────────────────────
219
220export const INJECT_CAP_START = 4000
221export const INJECT_CAP_PROMPT = 2000
222export const INJECT_MAX_ENTRIES = 20
223export const WATCH_CAP = 200
224
225export const suspectKey = (s: Suspect): string => `${s.child}|${s.entry.ref}|${s.entry.anchor ?? ''}|${s.reason}|${s.current ?? ''}`
226
227// Suspects the model has not been told about yet.
228export function newSuspects(ix: Index, all: readonly Suspect[] = suspectsOf(ix)): Suspect[] {
229 const seen = new Set(ix.injected ?? [])
230 return all.filter(s => !seen.has(suspectKey(s)))
231}
232
233// Records `told` as injected and forgets keys that are no longer suspect, so a suspect that clears and comes back is told again.
234export function markInjected(ix: Index, told: readonly Suspect[]): void {
235 const live = new Set(suspectsOf(ix).map(suspectKey))
236 const keep = new Set([...(ix.injected ?? []), ...told.map(suspectKey)])
237 ix.injected = [...keep].filter(k => live.has(k)).sort()
238}
239
240const line = (s: Suspect): string => `- ${s.child} <- ${s.entry.ref}${s.entry.anchor ? `#${s.entry.anchor}` : ''} (${s.reason})`
241
242// One block for the model: at most INJECT_MAX_ENTRIES lines then "N more", at most `cap` chars. Null when there is nothing to say.
243export function formatSuspects(head: string, list: readonly Suspect[], cap: number): string | null {
244 if (list.length === 0) return null
245 const shown = list.slice(0, INJECT_MAX_ENTRIES).map(line)
246 let more = list.length - shown.length
247 let body = `${head}\n${shown.join('\n')}`
248 while (shown.length > 1 && body.length + (more ? 20 : 0) > cap) {
249 shown.pop()
250 more++
251 body = `${head}\n${shown.join('\n')}`
252 }
253 return more ? `${body}\n- ${more} more (see /keel)` : body
254}
255
256export const GUIDE = 'For each: update the derived file, OR if its content still holds replace the stamp with @ok.'
257
258// ── turn, Stop block (§7, D10), pure ────────────────────────────────────────
259
260export const STOP_CAP = 10000
261export const STOP_MAX = 10
262
263// A turn begins (SessionStart, prompt.submit): what is suspect now is old news, the Stop block is available again.
264export function startTurn(ix: Index): void {
265 ix.turn = suspectsOf(ix).map(suspectKey).sort()
266 delete ix.blocked
267 delete ix.toasted
268}
269
270// The host reports a file keel rewrote as "edited"; the agent must be told that edit is keel's stamp, not someone else's.
271export function noteStamped(ix: Index, files: readonly string[]): void {
272 if (files.length) ix.stamped = [...new Set([...(ix.stamped ?? []), ...files])].sort()
273}
274
275export function formatStamped(files: readonly string[]): string {
276 const shown = files.length > STOP_MAX ? `${files.slice(0, STOP_MAX).join(', ')} and ${files.length - STOP_MAX} more` : files.join(', ')
277 return `keel re-stamped ${shown} (stamp lines only). A notice that ${files.length > 1 ? 'these files were' : 'this file was'} edited is keel's own write, not someone else's edit.`
278}
279
280// Suspects that did not exist when the turn began. Without a turn base nothing is "created this turn": no base, no block.
281export function createdThisTurn(ix: Index): Suspect[] {
282 if (!ix.turn) return []
283 const base = new Set(ix.turn)
284 return suspectsOf(ix).filter(s => !base.has(suspectKey(s)))
285}
286
287// Stamps the first sighting of each live suspect and forgets the cleared ones.
288export function touchSince(ix: Index, now: number): void {
289 const live = suspectsOf(ix).map(suspectKey)
290 const next: Record<string, number> = {}
291 for (const k of live) next[k] = ix.since?.[k] ?? now
292 ix.since = next
293}
294
295const OMITTED = ' diff: omitted (message cap)'
296
297export type StopItem = { s: Suspect; diff: string }
298
299// §7: the message of the one Stop block per turn. At most STOP_CAP chars: the diffs share what the list and the footer leave (each is already capped by DIFF_CAP).
300export function formatStopBlock(items: readonly StopItem[], more: number, rewritten: readonly string[], cap = STOP_CAP): string {
301 const head = 'keel: sources changed this turn, derived files not reviewed:'
302 const tail = [GUIDE, ...(rewritten.length ? [`keel rewrote stamps in: ${rewritten.join(', ')} (re-read before editing).`] : [])]
303 const moreLine = more > 0 ? [`- ${more} more (see /keel)`] : []
304 // each item may end up with the 'omitted' line instead of a diff: reserve it up front so the cap holds
305 let room = cap - [head, ...moreLine, ...tail].join('\n').length - items.reduce((n, i) => n + line(i.s).length + 1 + OMITTED.length + 1, 0) - 1
306 const out = [head]
307 for (const it of items) {
308 out.push(line(it.s))
309 if (!it.diff) continue
310 const body = it.diff.split('\n').map(l => ` ${l}`).join('\n')
311 const cost = body.length + '\n diff:'.length
312 if (cost <= room) {
313 out.push(' diff:', body)
314 room -= cost
315 } else if (room > 200) {
316 out.push(' diff:', `${body.slice(0, room - 60).trimEnd()}\n ... diff truncated (message cap)`)
317 room = 0
318 } else out.push(OMITTED)
319 }
320 return [...out, ...moreLine, ...tail].join('\n')
321}
322
323// §9: keel-status-llm.md. Rewritten at Stop while the index is non-empty. The `swept:` line is the only one that moves on its own (see sameStatus).
324export function statusText(ix: Index, iso: string): string {
325 const list = suspectsOf(ix)
326 const rows = list.map(s => {
327 const t = ix.since?.[suspectKey(s)]
328 return `- ${s.child} <- ${s.entry.ref}${s.entry.anchor ? `#${s.entry.anchor}` : ''} (${s.reason})${t === undefined ? '' : ` since ${new Date(t).toISOString()}`}`
329 })
330 return ['---', 'type: keel-status', `swept: ${iso}`, '---', `suspects: ${list.length ? list.length : 'none'}`, ...rows, ...(ix.degraded.length ? ['degraded:', ...ix.degraded.map(d => `- ${d}`)] : []), ''].join('\n')
331}
332
333export const sameStatus = (a: string, b: string): boolean => a.replace(/^swept: .*$/m, '') === b.replace(/^swept: .*$/m, '')
334
335// Suspects that hang off one of `targets` (sources that just moved).
336export const derivedFrom = (ix: Index, targets: readonly string[]): Suspect[] => suspectsOf(ix).filter(s => s.entry.target !== null && targets.includes(s.entry.target))
337
338// Absolute paths of the indexed sources that exist, for the host's file watcher (classic.SessionStart watchPaths).
339export const watchTargets = (ix: Index): string[] =>
340 indexedTargets(ix)
341 .filter(t => ix.sources[t] && !ix.sources[t]!.missing)
342 .map(t => absOf(ix.root, t))
343 .slice(0, WATCH_CAP)
344
345// ── command replies (§10), pure ────────────────────────────────────────────
346
347export const REPLY_MAX = 50
348
349export function stateLabel(ix: Index, e: EntryRec): string {
350 const st = assessEntry(ix, e)
351 switch (st.kind) {
352 case 'clean':
353 return 'clean'
354 case 'pending-stamp':
355 return 'no stamp yet (written at the next turn end)'
356 case 'ok-requested':
357 return '@ok (re-stamped at the next turn end)'
358 case 'suspect':
359 return `suspect: ${st.reason}`
360 case 'opaque':
361 return 'opaque (never checked)'
362 case 'unresolved':
363 return 'unresolved (never checked)'
364 case 'unreadable':
365 return `unreadable: ${st.why}`
366 }
367}
368
369const stampLabel = (e: EntryRec): string => (e.stamp.kind === 'none' ? 'no stamp' : e.stamp.kind === 'ok' ? '@ok' : e.stamp.kind === 'hex' ? `@${e.stamp.value}` : `@${e.stamp.raw} (bad)`)
370
371// `/keel`: the suspects with reason and age, then the size of the index and what is degraded.
372export function formatOverview(ix: Index, iso: (ms: number) => string): string {
373 const children = Object.keys(ix.children).length
374 if (children === 0) return `keel: nothing declared under ${ix.root} (no sources: found). A derived file created outside the agent's tools is only seen by /keel scan.${ix.degraded.length ? `\ndegraded: ${ix.degraded.join('; ')}` : ''}`
375 const list = suspectsOf(ix)
376 const rows = list.slice(0, REPLY_MAX).map(s => {
377 const t = ix.since?.[suspectKey(s)]
378 return `${line(s)}${t === undefined ? '' : ` since ${iso(t)}`}`
379 })
380 const out = [list.length ? `keel: ${list.length} suspect${list.length > 1 ? 's' : ''}` : 'keel: no suspect', ...rows]
381 if (list.length > rows.length) out.push(`- ${list.length - rows.length} more (see keel-status-llm.md)`)
382 out.push(`${children} derived file${children > 1 ? 's' : ''}, ${indexedTargets(ix).length} source${indexedTargets(ix).length > 1 ? 's' : ''}${ix.sweptAt ? `; swept ${iso(ix.sweptAt)}` : ''}`)
383 if (ix.degraded.length) out.push(`degraded: ${ix.degraded.join('; ')}`)
384 return out.join('\n')
385}
386
387// `/keel why <file>`: what the file derives from (with the state of each entry) and what derives from it.
388export function formatWhy(ix: Index, arg: string, target: string): string {
389 const own = ix.children[target]
390 const inbound = reverseOf(ix)[target] ?? []
391 if (!own && inbound.length === 0) return `keel: ${arg} is neither a derived file nor an indexed source.`
392 const out = [`keel why ${target}`]
393 if (own) {
394 out.push('derives from:')
395 for (const e of own.entries) out.push(`- ${sourceName(e)} ${stampLabel(e)} - ${stateLabel(ix, e)}`)
396 }
397 if (inbound.length) {
398 out.push('derived files:')
399 for (const child of inbound)
400 for (const e of ix.children[child]!.entries.filter(x => x.target === target)) out.push(`- ${child} (${sourceName(e)} ${stampLabel(e)} - ${stateLabel(ix, e)})`)
401 }
402 return out.join('\n')
403}
404
405// ── stamping (§3.4, D14), pure ──────────────────────────────────────────────
406
407// §3.4 `updated`: a suspect child written by the agent during the turn counts as reviewed for its changed entries. Not confirmed by Mat: one constant.
408export const UPDATED_RULE = true
409
410export type GestureKind = 'stamp' | 'ok' | 'updated' | 'manual'
411export type Gesture = { kind: GestureKind; child: string; source: string; old: string; stamp: string }
412export type StampOp = { child: string; entry: EntryRec; kind: 'stamp' | 'ok' | 'updated'; stamp: string }
413
414const WHO: Record<GestureKind, string> = { stamp: 'keel', ok: 'agent', updated: 'keel', manual: 'hand' }
415
416export const entryId = (e: EntryRec): string => `${e.kind}|${e.ref}|${e.anchor ?? ''}|${e.scope}`
417
418export const sourceName = (e: EntryRec): string => `${e.kind === 'wikilink' ? `[[${e.ref}]]` : e.ref}${e.anchor ? `#${e.anchor}` : ''}`
419
420const oldStamp = (e: EntryRec): string => (e.stamp.kind === 'none' ? 'none' : e.stamp.kind === 'ok' ? 'ok' : e.stamp.kind === 'hex' ? e.stamp.value : e.stamp.raw)
421
422export const gestureOf = (kind: GestureKind, child: string, e: EntryRec, stamp: string): Gesture => ({ kind, child, source: sourceName(e), old: oldStamp(e), stamp })
423
424// §9: `<iso> <stamp|ok|updated|manual> <child> <- <source>[#a] <old>-><new> (<who>)`
425export const journalLine = (g: Gesture, iso: string): string => `${iso} ${g.kind} ${g.child} <- ${g.source} ${g.old}->${g.stamp} (${WHO[g.kind]})`
426
427// What the next boundary writes: absent stamps, `@ok`, and (UPDATED_RULE) changed entries of a child written since the source moved last.
428export function stampPlan(ix: Index): StampOp[] {
429 const out: StampOp[] = []
430 for (const child of Object.keys(ix.children).sort())
431 for (const entry of ix.children[child]!.entries) {
432 const st = assessEntry(ix, entry)
433 if (st.kind === 'pending-stamp') out.push({ child, entry, kind: 'stamp', stamp: st.current })
434 else if (st.kind === 'ok-requested') out.push({ child, entry, kind: 'ok', stamp: st.current })
435 else if (UPDATED_RULE && st.kind === 'suspect' && st.reason === 'changed' && st.current && ix.reviewed?.[child]?.[entryId(entry)] === st.current) out.push({ child, entry, kind: 'updated', stamp: st.current })
436 }
437 return out
438}
439
440// The agent wrote `child`: remember, per changed entry, the source stamp it was written against. A later source move leaves the entry suspect.
441export function markReviewed(ix: Index, child: string): void {
442 if (!UPDATED_RULE) return
443 const rec = ix.children[child]
444 if (!rec) return
445 const seen: Record<string, string> = {}
446 for (const e of rec.entries) {
447 const st = assessEntry(ix, e)
448 if (st.kind === 'suspect' && st.reason === 'changed' && st.current) seen[entryId(e)] = st.current
449 }
450 if (Object.keys(seen).length) (ix.reviewed ??= {})[child] = { ...(ix.reviewed?.[child] ?? {}), ...seen }
451}
452
453// Entries whose stamp was changed by hand to the current hash (§3.4 `manual`): was a stale/bad stamp in `prev`, is now the current one.
454export function manualStamps(ix: Index, child: string, prev: ChildRec | undefined, next: ChildRec): Gesture[] {
455 if (!prev) return []
456 const out: Gesture[] = []
457 for (const e of next.entries) {
458 if (e.stamp.kind !== 'hex') continue
459 const was = prev.entries.find(p => entryId(p) === entryId(e))
460 if (!was || (was.stamp.kind !== 'hex' && was.stamp.kind !== 'bad') || oldStamp(was) === e.stamp.value) continue
461 if (assessEntry(ix, e).kind === 'clean') out.push(gestureOf('manual', child, was, e.stamp.value))
462 }
463 return out
464}
465
466// ── diff (§8, D9), pure ─────────────────────────────────────────────────────
467
468export const DIFF_CAP = 3000 // per diff, in the Stop block (K7)
469export const FEEDBACK_DIFF_CAP = 1200 // per diff, in the PostToolUse feedback
470export const FEEDBACK_DIFFS = 3 // diffs shown in one feedback block, the others are only listed
471export const BLOB_STORE_MAX = 50 // `git hash-object -w` calls per boundary
472
473// The text a stamp covers: the whole source, or the anchored section (same lookup as the index hash). Null when the heading is gone.
474export function stampedText(text: string, anchor: string | undefined): string | null {
475 if (!anchor) return text
476 return anchorSections(text).get(anchor.trimEnd())?.text ?? null
477}
478
479// `git diff` of two blobs starts with `diff --git a/<sha> b/<sha>`, `index ...`, `--- a/<sha>`, `+++ b/<sha>`: only the hunks mean something.
480export function trimDiff(out: string, cap: number): string | null {
481 const at = out.indexOf('@@')
482 const body = (at < 0 ? out : out.slice(at)).trimEnd()
483 if (!body) return null
484 return body.length <= cap ? body : `${body.slice(0, cap).trimEnd()}\n... diff truncated (${body.length - cap} more chars)`
485}
486
487// Only a `changed` entry with a hex stamp has an old blob to compare with.
488export const diffable = (s: Suspect): s is Suspect & { old: string; current: string } => s.reason === 'changed' && /^[0-9a-f]{12}$/.test(s.old ?? '') && !!s.current
489
490const BEFORE = new Set([' ', '\t', '"', "'", '[', ',', ';', ':'])
491const AFTER = new Set([' ', '\t', '"', "'", ']', ',', ';', '#', '\r'])
492
493// First occurrence of `raw` delimited like a list item / flow item / `;` item (so `a.md` never matches inside `data.md`).
494function findBounded(line: string, raw: string): number {
495 for (let i = line.indexOf(raw); i >= 0; i = line.indexOf(raw, i + 1)) {
496 const before = i === 0 ? ' ' : line[i - 1]!
497 const after = i + raw.length >= line.length ? ' ' : line[i + raw.length]!
498 if (BEFORE.has(before) && AFTER.has(after)) return i
499 }
500 return -1
501}
502
503// Replaces the stamp of each op's entry in place (the entry's line, its raw text). `failed` = ops whose raw text was not found where the parse saw it.
504export function rewriteStamps(text: string, ops: readonly { entry: EntryRec; stamp: string }[]): { text: string; done: number[]; failed: number[] } {
505 const lines = text.split('\n')
506 const done: number[] = []
507 const failed: number[] = []
508 ops.forEach((op, i) => {
509 const l = lines[op.entry.line]
510 const pos = l === undefined ? -1 : findBounded(l, op.entry.raw)
511 if (l === undefined || pos < 0) return void failed.push(i)
512 lines[op.entry.line] = l.slice(0, pos) + withStamp(op.entry.raw, op.stamp) + l.slice(pos + op.entry.raw.length)
513 done.push(i)
514 })
515 return { text: lines.join('\n'), done, failed }
516}
517
518const STORE_CAP = 4 * 1024 * 1024
519
520export const indexedTargets = (ix: Index): string[] => [...new Set(Object.values(ix.children).flatMap(c => c.entries.flatMap(e => (e.target === null ? [] : [e.target]))))].sort()
521
522// Drops section blobs first, then sources entirely, when the serialized index passes the store cap (§12).
523export function trimIndex(ix: Index): Index {
524 if (JSON.stringify(ix).length <= STORE_CAP) return ix
525 const lean: Index = { ...ix, sources: Object.fromEntries(Object.entries(ix.sources).map(([k, v]) => [k, { ...v, sections: {} }])), degraded: [...ix.degraded, 'section blobs dropped (index too large)'] }
526 return JSON.stringify(lean).length <= STORE_CAP ? lean : { ...lean, md: [], degraded: [...lean.degraded, 'index too large'] }
527}
528