Hands a long chat off on its own at the token line: /rollover, clear, resume (spec 0063); /handoff by hand after /rollover (spec 0057); /auto-handoff on|off…

For you, the owner of this Claude Code setup. Claude Code reads its setup from ~/.claude (the live dir). This repo keeps your own parts of that folder, names every downloaded skill, plugin and framework with where it came from, and keeps the retired wf runner as history in _archive/runner/. An AI working here reads AGENTS.md instead.
Each kind of file has one source of truth. The copy that wins is the one you edit; the other is overwritten.
| Kind | Repo folder | Live path | Source of truth | Who writes it | What moves it |
|---|---|---|---|---|---|
Copied (CLAUDE.md, settings*.json, agents/, rules/, rules-on-demand/, MAP.md, your hooks and scripts) | claude-home/ | ~/.claude/<same path> | live wins | you, in ~/.claude | tools/bin/sync.sh copies live into the repo; tools/bin/install.sh copies back on a new machine |
Linked (skills/, commands/ you wrote) | claude-home/skills/, claude-home/commands/ | ~/.claude/skills/<name> is a symlink (a pointer) into the repo | repo wins | you, in the repo | tools/bin/install.sh makes the links |
| Downloaded (skills from GitHub and packages) | tools/config/manifest.json, your edits in tools/config/patches/ | ~/.claude/skills/<name> | upstream plus your patch | their installer, then you | /claude-config update, after your yes per skill |
Runner (wf, next, retired 2026-10-21) | _archive/runner/ | none: it was copied to ~/.local/share/wf/ until it retired | repo wins | nobody; kept as history | nothing |
| Repo tools | tools/bin/, tools/scripts/ | none (a few are linked into ~/.local/bin) | repo wins | you, in the repo | nothing |
| Frameworks (gstack, jjstack, ECC commands, the JSM skills, plugins) | named in tools/config/manifest.json | their own folders in ~/.claude | their installer wins | their installer | their own installer; never copied here |
| Private (names, work rules, settings fragments, private skills and patches) | none | ~/.claude/private/ | live only | you | nothing; never in any repo |
Claude Code state (projects/, sessions/, history.jsonl, caches) | none | ~/.claude/... | Claude Code only | Claude Code | nothing; never synced |
claude-home/ mirrors ~/.claude: claude-home/rules/x.md installs to ~/.claude/rules/x.md. tools/config/ holds the manifest, patches, the routing table and the MCP config. Guides live in docs/guides/, specs (one per decision) in docs/specs/, the plan in docs/scope/, third party notices in docs/NOTICE.md.
Rules for one place only. Rules for this repo only go in CLAUDE.md, the entry file; AGENTS.md is a symlink to it, so a session in this repo loads the global instructions once and the entry file once. The home chat opens in ~/claude-config; its private rules are in CLAUDE.local.md (git ignored, spec 0067).
claude starts in a folder
|
+-- user memory ....... ~/.claude/CLAUDE.md (+ every @import in it, + ~/.claude/rules/*.md)
+-- project memory .... <folder>/CLAUDE.md or <folder>/.claude/CLAUDE.md, if present
+-- settings .......... ~/.claude/settings.json, then the folder's .claude/settings*.json
+-- hooks ............. scripts settings.json names, run on events
| (SessionStart, PreToolUse, Stop, ...)
+-- skills ............ ~/.claude/skills/<name>/SKILL.md: only the one line description
| loads; the body loads when the skill is invoked (/name or by match)
+-- skill rules ....... when a skill starts, hooks/skill-rules.py adds its files from
~/.claude/rules-on-demand/ (map.json); nothing else loads them
SKILL.md. Yours are linked from claude-home/skills/; the rest come from frameworks and downloads, all listed in docs/guides/SKILLS.md.tools/bin/sync.sh and the commit hooks refuse any of them reaching this public repo.| Guide | Kind | What it covers |
|---|---|---|
| SETUP.md | how to | Install, restore on a new machine, frameworks, downloaded skills, plugins, MCP servers, keeping it current |
| DAILY-USE.md | how to | The workflow chain, /claude-config, picking a skill, and the retired wf commands |
| SKILLS.md | reference | Every skill with its source and license, plus the routing table (generated) |
| HOOKS.md | reference | Each hook script, where it comes from, the settings it relies on |
| DESIGN-PIPELINE.md | how to | Setting up and running the design pipeline |
Also: docs/architecture.md (how the chat workflow, hooks, mods, workflows and the setup sync work) and docs/research/ (skills tried and kept or dropped, reviews).
Each rule below cost something on a real project.
| Rule | Where | Why it exists |
|---|---|---|
| An absent answer rendered as a definite one | claude-home/rules/ecc/common/code-review.md | A count that has not loaded is not zero; a failed fetch is not an empty list. One defaulted count switched off the guard on account deletion |
| Get the RED back when the test came second | claude-home/rules/ecc/common/testing.md | Bug fixes skip watching the test fail, so break the source, see the test go red, restore |
| A test that reimplements the logic passes forever | claude-home/rules/ecc/common/testing.md | A test with a local copy of the behaviour stays green when the real fix is reverted |
| A mock weaker than the real module passes anything | claude-home/rules/ecc/common/testing.md | A mock missing one field hid a control that could never render |
| Shell discipline | claude-home/CLAUDE.md | A stale cd looks like a deleted tree; $(...) does not expand in a quoted heredoc |
| Grep before writing against a symbol | claude-home/CLAUDE.md | A helper that reads plausibly may not exist |
The ECC language rules are not loaded on their own; DAILY-USE.md says how to load one per project.
Languages Markdown (skills, agents, rules), Bash, Python 3 stdlib, one Node ESM script
Key dependencies jq (tools/bin/sync.sh), fontTools (design font scripts), culori (build-ramp.mjs,
node_modules committed on purpose); no package manager at the root
Plugins frontend-design, code-review, feature-dev,
code-simplifier, ralph-loop, clangd-lsp, ponytail, security-guidance
Skill frameworks gstack, then jjstack on top;
ECC commands; the JSM skills; sentry skills; anthropic security review
Downloaded skills about 175, each with its source and license in tools/config/manifest.json
MCP servers context7 (library docs), plus the design MCPs in DESIGN-PIPELINE.md
tools/bin/install.sh --dry-run, then tools/bin/install.sh.~/.claude.json.hooks/register.ts 800 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2import { extractFacts, factsBlock, fillTemplate, insertAfterHeading, lastRequestStatus, markUnverified, notLive, notLiveNotice, type Numbers } from '../facts.ts'
3import { addNext, renderPage } from '../pages.ts'
4
5type Phase = 'idle' | 'gated' | 'rolling' | 'clearing' | 'seeding' | 'paused' | 'failed'
6type Chain = {
7 root: string; session: string; previous: string | null; startTokens: number | null; unattended: number
8 triggeredAt: string | null; phase: Phase; lastRequest: string | null; link?: string; pageKey?: string
9 answered?: boolean // false from an owner prompt until a turn ends without the gate
10 // /auto-handoff off: this chat never hands off on its own. Copied from the folder's off file
11 // (offFile) at every chat start and clear, so the chain alone no longer decides it.
12 off?: boolean
13 // the line in force at the last save, for the chip-dash band (it reads this file, no shared code)
14 line?: number
15 // when this chat started (ISO): the handover's commits are `git log --since` it
16 startedAt?: string
17 // spec 0070 AC-1: the chat id /audit or /scope was started for (or skipped for), once per chat
18 startedFor?: string
19}
20
21const GATE_TEXT = 'context is at the handoff line; finish this turn in words, the handoff starts at the end of it'
22const HOLD_TEXT = 'held for the handoff; it is sent for you, clear the box'
23const PAUSE_TEXT = 'auto handoff paused: type anything to resume'
24const EXEMPT = ['rollover', 'save-and-clear', 'handoff']
25const CLEAR_TIMEOUT_MS = 15000
26// shortcut: the log is cut to its newer half past this size; one log is shared by every chat
27const LOG_MAX = 256000
28// Spec 0064 AC-1: the steps the chip-dash band draws from panel.json.
29const STEPS = ['writing the handover', 'clearing', 'starting the fresh chat']
30const STICKY_TOAST_MS = 30000
31// The gate used to refuse the very next tool call, so a promised step (a file save, a send to the
32// phone, a note to a helper) stopped halfway: a gated turn gets this many more calls first, enough
33// for a write, a send and its check.
34const GRACE_CALLS = 3
35// Past the line by this much every tool call is refused, an owner's turn and the grace included.
36const HARD_MARGIN = 20000
37// A handoff used to stop a running helper agent, which was then relaunched from zero: it waits for
38// this chat's background agents first, checking every AGENT_POLL_MS, at most AGENT_WAIT_MS.
39const AGENT_WAIT_MS = 15 * 60000
40const AGENT_POLL_MS = 5000
41const NO_MEMORY = 'memory not written: the handover names no index (the writer did not run, or its result was not written)'
42
43let opts: PluginOptions = {}
44// The harness fills options.threshold from plugin.json; this fallback must equal that default (edges.test.ts).
45export const DEFAULT_THRESHOLD = 250000
46let threshold = DEFAULT_THRESHOLD
47let headroom = 40000
48let maxInRow = 2
49let ignoreFiles = ''
50
51let active = false
52let home = ''
53let envTokens: number | null = null
54let dir = ''
55let chain: Chain | null = null
56let pending = 0 // tool output characters the last usage figure does not hold yet
57let exemptTurn = false
58let exemptNext = false // an exempt command typed mid turn: it runs as the next turn
59let requestAtTrigger: string | null = null
60let answeredAtTrigger = true
61let armedTurn: string | null = null
62let currentTurn: string | null = null
63let rollResolved = false
64let awaitStart = false
65let counted = false
66let held: string[] = []
67let clearGen = 0
68let lateClear = false // a timed out handoff clear may still end the chat
69let pointed = ''
70let logQueue = Promise.resolve()
71let panelStep = -1 // the running step of the panel, STEPS.length once all are done
72let panelResult: string | null = null
73let handoffTokens = 0
74let viewerBase = '' // http://<bind>:<port>, or '' for file links
75let nextFor: { page: string; link: string } | null = null
76let transcript: string | null = null // the handed off chat's own log, found once per handoff
77let notLiveAtHandoff: string[] = [] // open PRs and branches not on main at the handoff
78let folder = '' // realpath of the folder this chat started in
79let ownerNext = false // an owner message typed while idle: the next turn starts with it
80let ownerTurn = false // this turn started with a fresh owner message: never gated below HARD_MARGIN
81let graceLeft = 0
82let waitingAgents = false
83// Spec 0069 AC-3: the rollover turn starts the memory writer with the Agent tool, which always runs it
84// in the background (probe 2026-10-22), so that turn ends before the handover is written; the slot
85// check waits for running agents and for a main turn begun after they finished.
86let writerLaunched = false
87let afterAgents = false
88let rollGen = 0
89// session.start waits on the other start hooks (MCP servers connecting, tens of seconds after
90// `claude -c`); an /auto-handoff typed in that window used to find no chain and say the chat
91// does not run the handoff (2026-10-22).
92let startDone = false
93let startWaiters: (() => void)[] = []
94let pendingScope = false // spec 0070 AC-1: /scope runs when the started /audit turn completes
95const startFinished = () => { startDone = true; startWaiters.splice(0).forEach(f => f()) }
96
97// The rollover skill took "the newest log in the project folder" as this chat's transcript: a
98// background agent's newer log was named instead once, and the search plus its 27k character
99// listing cost a model round trip in each rollover and another in the fresh chat.
100async function transcriptOf($: EngineInterface, id: string) {
101 const base = `${home}/.claude/projects`
102 for (const d of await $.fs.list(base).catch(() => [])) {
103 const p = `${base}/${d.name}/${id}.jsonl`
104 if (await $.fs.stat(p).catch(() => null)) return p
105 }
106 return null
107}
108
109async function slotCmd($: EngineInterface) {
110 const cwd = await $.session.cwd()
111 return $.process.run(
112 [`${home}/.claude/skills/jjstack/bin/jjstack-rollover-slot`, '--cwd', cwd, 'status', '--max-age-days', '1'],
113 { timeoutMs: 2000 },
114 ).catch(() => ({ exitCode: 1, stdout: '' }))
115}
116
117// Off used to live only on the chain of the chat it was typed in, so a new, resumed, forked or
118// owner cleared chat in the same folder got a fresh chain and handed off at the line anyway
119// (row 95, 2026-10-22). It is now one file per folder: 'off' or 'on', last word wins.
120const offFile = () => `${dir}/off-${encodeURIComponent(folder)}`
121const folderOff = async ($: EngineInterface) => (await $.fs.read(offFile()).catch(() => '')).trim() === 'off'
122
123function log($: EngineInterface, text: string) {
124 logQueue = logQueue.then(async () => {
125 const path = `${dir}/auto-handoff.log`
126 // this used to grow forever, and every line rereads and rewrites the whole file
127 let old = await $.fs.read(path).catch(() => '')
128 if (old.length > LOG_MAX) old = old.slice(old.indexOf('\n', old.length - LOG_MAX / 2) + 1)
129 const now = new Date(await $.clock.now()).toISOString()
130 await $.fs.write(path, `${old}${now} ${chain?.session ?? '-'} ${text}\n`)
131 }).catch(() => {})
132 return logQueue
133}
134
135async function save($: EngineInterface) {
136 if (!chain) return
137 chain.line = lineNow().line
138 await $.fs.write(`${dir}/${chain.root}.json`, JSON.stringify(chain, null, 2) + '\n').catch(() => {})
139 // session.start finds a handed off chat's chain through this pointer instead of parsing every chain file
140 if (chain.session !== chain.root && pointed !== chain.session) {
141 pointed = chain.session
142 await $.fs.write(`${dir}/${chain.session}.root`, chain.root).catch(() => {})
143 }
144}
145
146async function setPhase($: EngineInterface, phase: Phase) {
147 chain!.phase = phase
148 await save($)
149 await writePanel($)
150}
151
152// AC-1: one file for the band; `previous` lets the fresh chat match a panel its old id wrote.
153async function writePanel($: EngineInterface) {
154 const c = chain!
155 const failed = c.phase === 'failed'
156 const steps = STEPS.map((label, i) => ({
157 label, state: i < panelStep ? 'done' : i === panelStep ? (failed ? 'failed' : 'running') : 'pending',
158 }))
159 const p = {
160 session: c.session, previous: c.previous, phase: c.phase, steps, result: panelResult,
161 sticky: failed || c.phase === 'paused', link: c.link ?? null, at: await $.clock.now(),
162 }
163 await $.fs.write(`${dir}/panel.json`, JSON.stringify(p) + '\n').catch(() => {})
164}
165
166// AC-3: a toast only where no band draws the panel: chip-dash not loaded, or a surface with no band
167// (the host raises the band on the terminal and the desktop only).
168async function notify($: EngineInterface, text: string, sticky = false) {
169 const surfaces = await $.session.surfaces().catch(() => [] as readonly string[])
170 const dash = surfaces.some(s => s !== 'terminal' && s !== 'desktop') ? null
171 : await $.state.get({ plugin: 'chip-dash', key: 'facts' } as any).catch(() => null)
172 if (dash && dash.version > 0) return
173 $.ui.toast(text, sticky ? { timeoutMs: STICKY_TOAST_MS } : undefined)
174}
175
176const k = (n: number) => `${Math.round(n / 1000)}k`
177
178function randomKey() {
179 return [...crypto.getRandomValues(new Uint8Array(8))].map(b => b.toString(16).padStart(2, '0')).join('')
180}
181
182// AC-5: Tailscale's IPv4 address when `tailscale ip -4` answers, else loopback; never any other.
183async function startServer($: EngineInterface) {
184 const v = String(opts.viewer ?? 'tailscale:3846').trim()
185 if (!v) return
186 const m = v.match(/^(?:(.+):)?(\d{1,5})$/)
187 if (!m || Number(m[2]) > 65535) { await log($, `viewer setting "${v}" is not [host:]port; no server`); return }
188 const port = Number(m[2])
189 const ts = await $.process.run(['tailscale', 'ip', '-4'], { timeoutMs: 2000 }).catch(() => null)
190 const tsIp = ts?.exitCode === 0 ? ts.stdout.trim().split('\n')[0]!.trim() : ''
191 const loopback = m[1] === '127.0.0.1' || m[1] === 'localhost'
192 const bind = !loopback && /^100\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tsIp) ? tsIp : '127.0.0.1'
193 viewerBase = `http://${bind}:${port}`
194 for (const host of new Set([bind, '127.0.0.1', tsIp].filter(Boolean))) {
195 // server.py --probe checks the server signs this fresh nonce with the private token (this runtime
196 // has no HMAC); an answer recorded earlier signs an old nonce and is not ours
197 const r = await $.process.run(['python3', `${$.plugin.root}/server.py`, '--probe', host, String(port), `${dir}/server.token`, randomKey()], { timeoutMs: 5000 }).catch(() => null)
198 const info = (() => { try { return JSON.parse(r?.stdout ?? '') } catch { return null } })()
199 if (!info || info.state === 'none') continue
200 if (info.state !== 'ours') {
201 // never send the phone to a listener we cannot prove is ours: file links instead
202 viewerBase = ''
203 await log($, `port ${port} on ${host} is taken by something else; no server, file links`)
204 return
205 }
206 if (info.bind === bind) return // another session serves already
207 // Tailscale came up (or went): stop the old server, but only a process that is ours.
208 const cmd = await $.fs.read(`/proc/${Number(info.pid)}/cmdline`).catch(() => '')
209 if (!/wf-handoff\/server\.py/.test(cmd)) { await log($, `server on ${host}:${port} is not this mod's; left alone`); return }
210 await $.process.run(['kill', String(Number(info.pid))]).catch(() => null)
211 await log($, `stopped the server on ${info.bind}, now ${bind}`)
212 }
213 void (async () => {
214 try {
215 const out = $.process.spawn({ argv: ['python3', `${$.plugin.root}/server.py`, '--bind', bind, '--port', String(port), '--dir', `${dir}/pages`, '--token-file', `${dir}/server.token`] })
216 for await (const { text } of out) {
217 // the port went to something else between the probe and the bind: no links to it
218 if (/not free|refused bind/.test(text)) viewerBase = ''
219 await log($, `server: ${text.trim()}`)
220 }
221 } catch (err) {
222 await log($, `server did not start (${err})`)
223 }
224 })()
225}
226
227function lineNow() {
228 const base = envTokens ?? threshold
229 const source = envTokens != null ? 'AUTO_HANDOFF_TOKENS' : 'threshold setting'
230 const start = chain?.startTokens
231 if (start != null && start + headroom > base) return { line: start + headroom, source: 'start plus headroom' }
232 return { line: base, source }
233}
234
235async function size($: EngineInterface) {
236 return ((await $.session.usage()).context.tokens ?? 0) + Math.ceil(pending / 4)
237}
238
239// True when the line is crossed and the mod may act; past the guard it pauses instead.
240async function crossed($: EngineInterface) {
241 if (!chain || chain.off || exemptTurn || (chain.phase !== 'idle' && chain.phase !== 'gated')) return false
242 if (chain.phase === 'gated') return true
243 if ((await size($)) <= lineNow().line) return false
244 if (chain.unattended >= maxInRow) {
245 panelStep = -1; panelResult = PAUSE_TEXT
246 await setPhase($, 'paused')
247 await notify($, PAUSE_TEXT, true); $.ui.status(PAUSE_TEXT)
248 await log($, `paused after ${chain.unattended} handoffs in a row`)
249 return false
250 }
251 return true
252}
253
254async function arm($: EngineInterface, turnId: string | null, why: string) {
255 if (chain!.phase !== 'gated') await log($, `trigger: ${why}, size ${await size($)}, line ${lineNow().line}`)
256 armedTurn = turnId
257 if (chain!.phase !== 'gated') { panelStep = -1; panelResult = null; graceLeft = GRACE_CALLS }
258 await setPhase($, 'gated')
259}
260
261async function fail($: EngineInterface, text: string) {
262 if (!counted) { chain!.unattended++; counted = true }
263 panelResult = `auto handoff failed: ${text}`
264 await setPhase($, 'failed')
265 panelStep = -1
266 await notify($, panelResult, true)
267 $.ui.status(`auto handoff failed: ${text}`)
268 await log($, `failed: ${text}`)
269 await resendHeld($)
270}
271
272async function resendHeld($: EngineInterface) {
273 const resend = held
274 held = []
275 for (const text of resend) {
276 const m = text.match(/^\/(\S+)\s*([\s\S]*)$/)
277 if (m) void $.command.run({ command: m[1]!, args: m[2] ?? '' }).catch(() => {})
278 else void $.prompt.submit({ text }).catch(() => {})
279 await log($, 'held prompt sent again')
280 }
281}
282
283async function template($: EngineInterface, name: 'sections' | 'instructions') {
284 const set = String(opts[`${name}Template`] ?? '').trim()
285 const path = set ? set.replace(/^~(?=\/)/, home) : `${home}/.claude/auto-handoff/${name}.md`
286 return $.fs.read(path).catch(() => $.fs.read(`${$.plugin.root}/templates/${name}.md`))
287}
288
289async function agentsRunning($: EngineInterface) {
290 const list = await $.agent.list().catch(() => [])
291 return list.filter(a => a.status === 'pending' || a.status === 'running' || a.status === 'waiting').length
292}
293
294// Every caller is in the gated phase; the wait runs off the hook, so turns and prompts go on
295// meanwhile (held, as in any gated phase), and an owner /clear during it drops the handoff.
296async function startHandoff($: EngineInterface) {
297 if (waitingAgents) return
298 const busy = await agentsRunning($)
299 if (!busy) return beginHandoff($)
300 waitingAgents = true
301 await log($, `waiting for ${busy} background agent(s) before the handoff`)
302 void (async () => {
303 const until = (await $.clock.now()) + AGENT_WAIT_MS
304 while ((await agentsRunning($)) && (await $.clock.now()) < until) await $.clock.sleep(AGENT_POLL_MS)
305 waitingAgents = false
306 if (chain?.phase !== 'gated') return
307 await log($, (await agentsRunning($)) ? `agents still running after ${AGENT_WAIT_MS / 60000} min; handoff starts` : 'background agents done; handoff starts')
308 await beginHandoff($)
309 })()
310}
311
312async function beginHandoff($: EngineInterface) {
313 const c = chain!
314 c.triggeredAt = new Date(await $.clock.now()).toISOString()
315 rollResolved = false
316 writerLaunched = false; afterAgents = false; rollGen++
317 counted = false
318 requestAtTrigger = c.lastRequest
319 answeredAtTrigger = c.answered !== false
320 panelStep = 0; panelResult = null
321 await setPhase($, 'rolling')
322 await notify($, `auto handoff started: ${STEPS[0]}`)
323 transcript = await transcriptOf($, c.session)
324 const args = fillTemplate(await template($, 'sections'), {
325 priority: false, lastRequest: requestAtTrigger ?? '(none typed in this chat)', transcript,
326 })
327 await log($, `rollover started${transcript ? '' : ' (transcript not found)'}`)
328 // Probe P1: slash commands go through $.command.run (prompt.submit refuses them); P6: it
329 // resolves when the rollover turn starts, so its end is the next main turn.complete.
330 $.command.run({ command: 'rollover', args }).then(
331 () => { rollResolved = true },
332 err => fail($, `the rollover did not start (${err})`),
333 )
334}
335
336// Every repo this chat touched: its folder plus each edited file's folder, one per git repo
337// (a worktree shares its repo's common dir). The chat that started in ~/.claude, no repo, edited
338// the claude-config plan, so its own folder alone would have found nothing (2026-10-22).
339async function notLiveNow($: EngineInterface, files: string[]) {
340 const run = (argv: string[]) => $.process.run(argv, { timeoutMs: 10000 }).catch(() => null)
341 const seen = new Set<string>()
342 const out: string[] = []
343 for (const d of [...new Set([await $.session.cwd(), ...files.map(f => f.replace(/\/[^/]*$/, '') || '/')])].slice(0, 20)) {
344 const r = await run(['git', '-C', d, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
345 const common = r?.exitCode === 0 ? r.stdout.trim() : ''
346 if (!common || seen.has(common)) continue
347 seen.add(common)
348 // gh has no -C and finds its repo from the folder it runs in; `env -C` sets that folder
349 const gh = await run(['env', '-C', d, 'gh', 'pr', 'list', '--author', '@me', '--state', 'open', '--json', 'number,title,url,isDraft', '--limit', '20'])
350 const br = await run(['git', '-C', d, 'branch', '--no-merged', 'origin/HEAD', '--format=%(refname:short) %(upstream:track)'])
351 out.push(...notLive(d, gh?.exitCode === 0 ? gh.stdout : null, br?.exitCode === 0 ? br.stdout : null))
352 }
353 return out
354}
355
356// The one place both paths (auto and a manual /rollover then /handoff) get the facts and the not live
357// list from. Only the auto path called it before (row 103), so a manual handover never named them.
358async function gatherFacts($: EngineInterface) {
359 // Commits come from git, not from commit output: `git commit -q` prints none and an amend
360 // printed a sha that no longer exists ("Commits: none" after 3 amends).
361 const gitLog = chain?.startedAt
362 ? await $.process.run(['git', '-C', await $.session.cwd(), 'log', `--since=${chain.startedAt}`, '-n', '30', '--format=%h %s'], { timeoutMs: 5000 }).catch(() => null)
363 : null
364 const facts = extractFacts(await $.session.messages(), ignoreFiles, gitLog?.exitCode === 0 ? gitLog.stdout : '')
365 return { facts, notLive: await notLiveNow($, facts.files) }
366}
367
368// The slot written since `since` (ISO), or ''.
369async function newSlot($: EngineInterface, since = chain!.triggeredAt!) {
370 const r = await slotCmd($)
371 const path = r.exitCode === 0 ? r.stdout.trim() : ''
372 const stat = path ? await $.fs.stat(path).catch(() => null) : null
373 return stat && stat.mtimeMs > Date.parse(since) ? path : ''
374}
375
376// A manual /rollover wrote this slot with no facts block; the auto path stamps it in finishRollover.
377// A slot that already has the block (stamped by the auto path, or by an earlier /handoff or /clear) is
378// left alone. Returns a sentence for the owner when stamping failed, else ''.
379async function stampManualSlot($: EngineInterface, path: string) {
380 if (!active || !chain) return ''
381 try {
382 const text = await $.fs.read(path)
383 if (!text.includes('## Handoff facts')) {
384 const g = await gatherFacts($)
385 const n: Numbers = { tokens: await size($), line: lineNow().line, lineSource: lineNow().source, start: chain.startTokens, unattended: chain.unattended }
386 const marked = markUnverified(text, n)
387 await $.fs.write(path, insertAfterHeading(marked.text, factsBlock(g.facts, n, chain.link, g.notLive)))
388 }
389 return ''
390 } catch (err) {
391 await log($, `manual handoff: facts not added (${err})`)
392 return ` The not live list could not be added (${err}).`
393 }
394}
395
396// Each main turn.complete of the rollover. The probe's rollover turn ended while its writer still ran,
397// and the slot was checked before the handover existed: wait while an agent runs, or while a writer
398// ran but no main turn has begun since every agent finished; at most AGENT_WAIT_MS, then check once.
399let rollWaitGen = -1
400async function rollTurnDone($: EngineInterface) {
401 if ((await agentsRunning($)) || (writerLaunched && !afterAgents && !(await newSlot($)))) {
402 if (rollWaitGen === rollGen) return
403 rollWaitGen = rollGen
404 const gen = rollGen
405 await log($, 'rollover turn ended; waiting for its agents before the slot check')
406 $.clock.after(AGENT_WAIT_MS, async () => {
407 if (gen !== rollGen || !rollResolved || chain?.phase !== 'rolling') return
408 rollResolved = false
409 await log($, `agents still running after ${AGENT_WAIT_MS / 60000} min; slot checked`)
410 await finishRollover($)
411 })
412 return
413 }
414 rollResolved = false
415 await finishRollover($)
416}
417
418async function finishRollover($: EngineInterface) {
419 const c = chain!
420 const path = await newSlot($)
421 if (!path) {
422 await log($, 'slot missing: the rollover wrote no new handover')
423 return fail($, 'the rollover wrote no new handover; nothing was cleared')
424 }
425 await log($, `slot found: ${path}`)
426 const n: Numbers = {
427 tokens: await size($), line: lineNow().line, lineSource: lineNow().source,
428 start: c.startTokens, unattended: c.unattended,
429 }
430 handoffTokens = n.tokens
431 // AC-4 to AC-6: this handover's page and link, the one before it linked as previous.
432 const prevLink = c.link ?? null
433 const key = randomKey()
434 const page = `${dir}/pages/${c.session}.html`
435 c.pageKey = key
436 c.link = viewerBase ? `${viewerBase}/${c.session.slice(0, 8)}-${key}` : `file://${page}`
437 nextFor = prevLink && c.previous ? { page: `${dir}/pages/${c.previous}.html`, link: c.link } : null
438 const gathered = await gatherFacts($)
439 notLiveAtHandoff = gathered.notLive
440 const marked = markUnverified(await $.fs.read(path), n)
441 for (const f of marked.marked) await log($, `unverified figure: ${f}`)
442 // Spec 0069 AC-22: a handover that names no index says so, and the fresh chat tells the owner.
443 const named = /Handover memory: \S/.test(marked.text) || /memory not written: /i.test(marked.text)
444 if (!named) await log($, 'the handover names no memory index')
445 const text = named ? marked.text : `${marked.text.replace(/\n*$/, '')}\n\n${NO_MEMORY}\n`
446 const slot = insertAfterHeading(text, factsBlock(gathered.facts, n, c.link, notLiveAtHandoff))
447 await $.fs.write(path, slot)
448 await $.fs.write(page, renderPage(slot, { key, prev: prevLink, title: `Handover ${c.triggeredAt}` }))
449 .catch(err => log($, `page not written (${err})`))
450 panelStep = 1
451 await setPhase($, 'clearing')
452 const gen = ++clearGen
453 $.clock.after(1000, async () => {
454 // Probe P3: no session.start after /clear; the seed runs once the awaited clear resolved,
455 // when handover-start.sh has already loaded the slot text.
456 const timedOut = $.clock.sleep(CLEAR_TIMEOUT_MS).then(() => 'timeout' as const)
457 const cleared = $.command.run({ command: 'clear', args: '' }).then(() => 'ok' as const, () => 'error' as const)
458 const how = await Promise.race([cleared, timedOut])
459 if (gen !== clearGen || c.phase !== 'clearing') return
460 if (how !== 'ok') {
461 clearGen++ // a late resolve is ignored: no seed
462 if (how === 'timeout') { lateClear = true; void cleared.then(() => { lateClear = false }) }
463 return fail($, `the clear ${how === 'timeout' ? 'did not finish in 15 s' : 'failed'}; the chat may still have cleared`)
464 }
465 await seed($, slot)
466 })
467}
468
469// Spec 0069, amended 2026-10-22: the replay lost open questions the slot had kept, so the slot keeps its
470// Open questions section beside the memory. An open question in either list is open; a slot line that
471// starts with a memory id (Q2) is the same question and is listed once.
472function openQuestions(slot: string, index: string) {
473 const section = slot.match(/^## Open questions[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m)?.[1] ?? ''
474 // a numbered (`1.`) or indented line is a question too; only `- ` lines used to count
475 const fromSlot = section.split('\n').map(l => l.match(/^\s*(?:[-*]|\d+[.)])\s+(.*)$/)?.[1]?.trim() ?? '').filter(t => t && t !== 'none')
476 const memory = [...index.matchAll(/^- (Q\d+) \| open \| [^|]* \| ([^|]*?) \|/gm)].map(m => `${m[1]} ${m[2]!.trim()}`)
477 const ids = new Set(memory.map(q => q.split(' ')[0]))
478 return [...memory, ...fromSlot.filter(t => !ids.has(t.match(/^(Q\d+)\b/)?.[1] ?? ''))]
479}
480
481async function seed($: EngineInterface, slot: string) {
482 const c = chain!
483 c.previous = c.session
484 c.session = await $.session.id()
485 c.startedFor = c.session // spec 0070 AC-3: a seeded chat resumes the handover, never /audit or /scope
486 c.startTokens = null
487 c.startedAt = new Date(await $.clock.now()).toISOString()
488 c.unattended++
489 counted = true
490 awaitStart = true
491 pending = 0
492 panelStep = 2
493 await setPhase($, 'seeding')
494 if (nextFor) {
495 const { page, link } = nextFor
496 nextFor = null
497 await $.fs.read(page).then(html => $.fs.write(page, addNext(html, link))).catch(() => log($, `previous page ${page} not found`))
498 }
499 // The mod's own record wins; the handover's Status line can only add priority, never drop it.
500 const priority = (!!requestAtTrigger && !answeredAtTrigger) || lastRequestStatus(slot) === 'not answered'
501 // The request the rollover judged, not one held since (those are sent again below).
502 // the real path: one fresh chat in six spent 11 s listing logs to find it. `<previous transcript>` is the
503 // placeholder of the instructions copies written before {{transcript}} existed.
504 const filled = fillTemplate(await template($, 'instructions'), { priority, lastRequest: requestAtTrigger ?? '', transcript })
505 const notice = notLiveNotice(notLiveAtHandoff)
506 // the rollover writes markdown: a path in backticks or bold must still be read
507 const indexPath = slot.match(/Handover memory: (\S+)/)?.[1]?.replace(/^[`*_(<]+|[`*_)>]+$/g, '')
508 const index = indexPath ? await $.fs.read(indexPath).catch(() => '') : ''
509 const questions = openQuestions(slot, index)
510 const open = questions.length ? `Open questions, the handover's list and the memory's together (an open question in either one is open):\n${questions.map(q => `- ${q}`).join('\n')}` : ''
511 const args = [transcript ? filled.split('<previous transcript>').join(transcript) : filled, open, notice].filter(Boolean).join('\n\n')
512 await log($, `cleared; seed run (priority ${priority ? 'on' : 'off'})`)
513 $.command.run({ command: 'resume-from-clear', args }).catch(err =>
514 fail($, `the fresh chat did not start (${err}); the handover waits, type /resume-from-clear`))
515 await setPhase($, 'idle')
516 await resendHeld($)
517}
518
519// Spec 0070 AC-1 to AC-3: a chat in a git repo starts /audit (no AGENTS.md, then /scope when that
520// turn completes) or /scope by itself; the rule text alone was ignored on 2026-10-22. Never with a
521// handover waiting, and never /audit in a bot reviewed repo (orgs named in ~/.claude/private/work.md).
522// The origin is parsed (scp `git@host:org/repo`, ssh://, https://), never matched as a substring:
523// `url.includes('github.com:' + org + '/')` took an org whose name ends in a listed one, or that text
524// anywhere in the url, as bot reviewed. No origin is a local repo ('own'); an origin that does not parse
525// starts nothing ('bad'). workflow-start.sh holds the same rule; tests/start-cases.ts drives both.
526function originKind(url: string, work: string): 'own' | 'bot' | 'bad' {
527 if (!url) return 'own'
528 const m = url.match(/^(?:ssh|https?|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/([^/]+)\/.+$/i)
529 ?? url.match(/^(?:[^@/]+@)?([^/:]+):(?!\/\/)([^/]+)\/.+$/)
530 if (!m) return 'bad'
531 const orgs = [...work.matchAll(/github\.com[:/]([\w.-]+)\//g)].map(x => x[1]!.toLowerCase())
532 return m[1]!.toLowerCase() === 'github.com' && orgs.includes(m[2]!.toLowerCase()) ? 'bot' : 'own'
533}
534
535async function startSkills($: EngineInterface, id: string, cwd: string) {
536 // once per chat id: callers start only a chat with no chain of its own yet (a reload finds one)
537 if (!chain) return
538 chain.startedFor = id
539 await save($)
540 if ((await slotCmd($)).exitCode === 0) return log($, 'chat start: a handover waits; no /audit or /scope')
541 const run = (argv: string[]) => $.process.run(argv, { timeoutMs: 5000 }).catch(() => null)
542 const top = await run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'])
543 if (top?.exitCode !== 0 || !top.stdout.trim()) return
544 const root = top.stdout.trim()
545 const has = async (p: string) => !!(await $.fs.stat(`${root}/${p}`).catch(() => null))
546 // No list means every repo would read as own, and /audit could start in a work repo: start nothing.
547 const work = await $.fs.read(`${home}/.claude/private/work.md`).catch(() => null)
548 if (work === null) return log($, 'chat start: ~/.claude/private/work.md missing or unreadable; nothing started')
549 const origin = await run(['git', '-C', root, 'remote', 'get-url', 'origin'])
550 const kind = originKind(origin?.exitCode === 0 ? origin.stdout.trim() : '', work)
551 if (kind === 'bad') return log($, 'chat start: origin url not understood; nothing started')
552 const first = kind === 'bot' ? ((await has('docs/scope')) || (await has('.workflow/scope')) ? 'scope' : null)
553 : (await has('AGENTS.md')) ? 'scope' : 'audit'
554 if (!first) return log($, 'chat start: bot reviewed repo with no scope; nothing started')
555 pendingScope = first === 'audit'
556 await log($, `chat start: /${first} in ${root}`)
557 // Probe (c): not awaited, so the start hook returns before the command's turn runs.
558 void $.command.run({ command: first, args: '' }).catch(err => log($, `chat start: /${first} did not start (${err})`))
559}
560
561// Spec 0057 AC-10: /handoff replaces typing /clear after a /rollover. The clear empties the
562// context and handover-start.sh (SessionStart, clear) then loads the handover; that hook is
563// the fallback when this mod is gone or breaks on an update.
564// Spec 0063: the same mod also hands off on its own at the token line (AC-1 to AC-12).
565export const register: Register = (on, options) => {
566 opts = options
567 threshold = Number(options.threshold ?? DEFAULT_THRESHOLD)
568 headroom = Number(options.minHeadroom ?? 40000)
569 maxInRow = Number(options.maxConsecutiveHandoffs ?? 2)
570 ignoreFiles = String(options.ignoreFiles ?? '')
571
572 on('session.start', async ($, e, next) => {
573 const started = await next(e)
574 await $.command.register({ name: 'handoff', description: 'After /rollover: clear and load the waiting handover' })
575 await $.command.register({ name: 'auto-handoff', description: 'Auto handoff for chats in this folder: on, off, or no word to see it' })
576 home = (await $.env.get('HOME')) ?? ''
577 dir = `${home}/.claude/state/auto-handoff`
578 active = e.isInteractive && !(await $.env.get('WF_RUN')) && !(await $.env.get('TM_WORKER_NAME'))
579 && (await $.env.get('AUTO_HANDOFF_DISABLE')) !== '1'
580 if (!active) { startFinished(); return started }
581 const t = Number(await $.env.get('AUTO_HANDOFF_TOKENS'))
582 envTokens = t > 0 ? t : null
583 for (const name of ['sections', 'instructions']) {
584 const own = `${home}/.claude/auto-handoff/${name}.md`
585 if ((await $.fs.read(own).catch(() => null)) === null)
586 await $.fs.write(own, await $.fs.read(`${$.plugin.root}/templates/${name}.md`)).catch(() => {})
587 }
588 // the start folder, not session.cwd later (that follows a Bash cd); a link such as
589 // ~/claude-config resolves to the same folder as its target
590 const r = await $.process.run(['realpath', '--', e.cwd], { timeoutMs: 2000 }).catch(() => null)
591 folder = (r?.exitCode === 0 && r.stdout.trim()) || e.cwd
592 const id = await $.session.id()
593 // A reload of the module (settings change) keeps the chain from its state file.
594 const root = await $.fs.read(`${dir}/${id}.root`).then(t => t.trim()).catch(() => id)
595 const c = await $.fs.read(`${dir}/${root}.json`).then(t => JSON.parse(t) as Chain).catch(() => null)
596 chain = c?.session === id ? c : { root: id, session: id, previous: null, startTokens: null, unattended: 0, triggeredAt: null, phase: 'idle', lastRequest: null }
597 // Row 104: a resumed chat follows the folder both ways; with no word in the file it keeps its own.
598 const word = (await $.fs.read(offFile()).catch(() => '')).trim()
599 if (word === 'off' || word === 'on') chain.off = word === 'off'
600 chain.startedAt ??= new Date(await $.clock.now()).toISOString()
601 // A reload mid handoff loses the in-memory flags and timers that would finish it, so the
602 // phase would hold every typed prompt forever.
603 if (['gated', 'rolling', 'clearing', 'seeding'].includes(chain.phase)) {
604 await log($, `failed: the mod reloaded during ${chain.phase}`)
605 await setPhase($, 'failed')
606 $.ui.status('auto handoff failed: the mod reloaded mid handoff')
607 }
608 // the band reads on/off and the line from this file from the chat's first tick
609 else await save($)
610 startFinished()
611 void startServer($)
612 // A chain already kept for this id is a reload or a resume (probe (c): a reload fires
613 // session.start again and started /audit a second time): only a new chat starts a skill.
614 // `claude --resume` keeps the id and session.start already sees its history (29 messages in the
615 // review probe): with its chain file gone it used to get /audit mid conversation.
616 const history = await $.session.messages().catch(() => null) // unknown history: start nothing
617 if (c?.session !== id && history && !history.some(m => m.role === 'user')) void startSkills($, id, e.cwd).catch(err => log($, `chat start failed (${err})`))
618 return started
619 })
620
621 on('session.end', async ($, e, next) => {
622 // An owner's /clear after a manual /rollover (row 103): stamp the slot while this chat's messages
623 // and id are still readable, so before next(e). Not the handoff's own clear, and not a slot an
624 // older chat wrote (the slot must be newer than this chat's start).
625 if (active && e.reason === 'clear' && chain?.startedAt && chain.phase !== 'clearing' && !(chain.phase === 'failed' && lateClear)) {
626 const path = await newSlot($, chain.startedAt).catch(() => '')
627 if (path) await stampManualSlot($, path)
628 }
629 const r = await next(e)
630 // An owner's own /clear starts a new chain and gets no seed.
631 if (active && e.reason === 'clear' && chain && chain.phase === 'failed' && lateClear) {
632 // AC-7: the handoff's own clear, done after its 15 s timeout, is not the owner's /clear; the
633 // chain and its failed count go on into the new chat.
634 lateClear = false
635 const id = await $.session.id().catch(() => '')
636 if (id) { chain.previous = chain.session; chain.session = id; await save($) }
637 pending = 0; awaitStart = false; exemptTurn = false; exemptNext = false
638 } else if (active && e.reason === 'clear' && chain && chain.phase !== 'clearing') {
639 // An owner's /clear used to drop the off typed before it; the folder's off holds for every clear.
640 const off = await folderOff($)
641 chain = null
642 const id = await $.session.id().catch(() => '')
643 if (id) chain = { root: id, session: id, previous: null, startTokens: null, unattended: 0, triggeredAt: null, phase: 'idle', lastRequest: null, startedAt: new Date(await $.clock.now()).toISOString(), ...(off ? { off } : {}) }
644 pending = 0; awaitStart = false; exemptTurn = false; exemptNext = false; pendingScope = false
645 await save($)
646 // Spec 0070 AC-1, probe (d): inside session.end $.session.id() is still the old chat's id; a
647 // timer reads the new one, and the fresh chain is keyed by it so a reload does not start again.
648 const old = id
649 $.clock.after(500, () => (async () => {
650 const now = await $.session.id().catch(() => '')
651 if (!now || !chain || chain.phase !== 'idle') return
652 if (chain.session === old && now !== old) { chain.root = chain.session = now; await save($) }
653 await startSkills($, now, folder)
654 })().catch(err => log($, `chat start after /clear failed (${err})`)))
655 }
656 return r
657 })
658
659 on('prompt.submit', async ($, e, next) => {
660 if (!active || !chain) return next(e)
661 const typed = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
662 const owner = typed && !e.command && !e.text.startsWith('/')
663 if (typed) pendingScope = false // spec 0070 AC-1: the owner typed first, the queued /scope is dropped
664 if (typed && ['gated', 'rolling', 'clearing', 'seeding'].includes(chain.phase)) {
665 held.push(e.text)
666 if (owner) { chain.unattended = 0; chain.lastRequest = e.text; chain.answered = false; await save($) }
667 await log($, `prompt held during ${chain.phase}`)
668 // AC-2: an interrupted gated turn that never reached turn.complete hands off here.
669 if (chain.phase === 'gated' && !e.turnId) void startHandoff($)
670 return { drop: HOLD_TEXT }
671 }
672 if (owner) {
673 chain.unattended = 0
674 chain.lastRequest = e.text
675 chain.answered = false
676 // Gating the turn that answers it left the owner's newest message unanswered (audit fix 4)
677 if (!e.turnId) ownerNext = true
678 if (chain.phase === 'paused' || chain.phase === 'failed') {
679 chain.phase = 'idle'; panelResult = null; $.ui.status(undefined)
680 await writePanel($)
681 }
682 await save($)
683 }
684 if (EXEMPT.some(c => e.text === `/${c}` || e.text.startsWith(`/${c} `)) || EXEMPT.includes(e.command?.name ?? '')) {
685 if (e.turnId) exemptNext = true
686 else exemptTurn = true
687 }
688 return next(e)
689 })
690
691 on('tool.call', async ($, e, next) => {
692 if (!active || e.agentId) return next(e)
693 if (chain?.phase === 'rolling' && (e.tool === 'Agent' || e.tool === 'Task')) writerLaunched = true
694 if (await crossed($)) {
695 const hard = (await size($)) >= lineNow().line + HARD_MARGIN
696 // an owner's turn runs on and hands off at its end, unless it reaches the hard margin
697 if (hard || !ownerTurn) {
698 await arm($, currentTurn, `tool call ${e.tool} past the line`)
699 if (hard || graceLeft <= 0) {
700 await log($, `refused ${e.tool}`)
701 return { deny: GATE_TEXT }
702 }
703 graceLeft--
704 await log($, `allowed ${e.tool} at the gate (${graceLeft} more)`)
705 }
706 }
707 const r = await next(e)
708 pending += r.text?.length ?? 0
709 return r
710 })
711
712 on('turn.step', async function* ($, e, next) {
713 if (!active || e.agentId || !chain) return yield* next(e)
714 if (exemptNext && e.turnId !== currentTurn) { exemptNext = false; exemptTurn = true }
715 if (e.turnId !== currentTurn) { ownerTurn = ownerNext; ownerNext = false }
716 if (e.turnId !== currentTurn && chain.phase === 'rolling' && writerLaunched && !afterAgents && !(await agentsRunning($))) afterAgents = true
717 currentTurn = e.turnId
718 // AC-2 fallback: a gated turn ended without turn.complete (Esc); the next step hands off.
719 if (chain.phase === 'gated' && armedTurn !== e.turnId) {
720 void startHandoff($)
721 return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null }
722 }
723 if (chain.phase === 'idle' && !ownerTurn && (await crossed($))) {
724 await arm($, e.turnId, 'model request past the line')
725 // Probe P7: answering without next(e) sends no request and ends the turn.
726 return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null }
727 }
728 const r = yield* next(e)
729 pending = 0
730 if (awaitStart) {
731 // Probe P4: the size of a just cleared chat is known only after its first response.
732 const tokens = (await $.session.usage()).context.tokens
733 if (tokens != null) {
734 chain.startTokens = tokens; awaitStart = false; await save($); await log($, `fresh chat starts at ${tokens}`)
735 panelStep = STEPS.length; panelResult = `handed off \u00b7 ${k(handoffTokens)} -> ${k(tokens)}`
736 await writePanel($)
737 await notify($, `${panelResult} \u00b7 ${chain.link ?? ''}`)
738 }
739 }
740 return r
741 })
742
743 on('turn.complete', async ($, e, next) => {
744 const r = await next(e)
745 if (!active || e.agentId || !chain) return r
746 ownerTurn = false
747 if (chain.phase === 'rolling') {
748 if (rollResolved) { exemptTurn = false; await rollTurnDone($) }
749 return r
750 }
751 const wasExempt = exemptTurn
752 exemptTurn = false
753 // A gated turn was cut short, so it did not answer; any other turn ran to its end.
754 if (!wasExempt && chain.phase !== 'gated' && chain.answered === false) { chain.answered = true; await save($) }
755 if (!wasExempt && (chain.phase === 'gated' || (await crossed($)))) {
756 if (chain.phase !== 'gated') await arm($, e.turnId, 'turn ended past the line')
757 await startHandoff($)
758 }
759 if (pendingScope && chain.phase === 'idle') {
760 pendingScope = false
761 await log($, 'chat start: /audit turn done, /scope')
762 void $.command.run({ command: 'scope', args: '' }).catch(err => log($, `chat start: /scope did not start (${err})`))
763 }
764 return r
765 })
766
767 on('command.run', { command: 'handoff' }, async $ => {
768 home ||= (await $.env.get('HOME')) ?? ''
769 const r = await slotCmd($)
770 if (r.exitCode !== 0 || !r.stdout.trim()) return { text: 'no handover, run /rollover first' }
771 const stampError = await stampManualSlot($, r.stdout.trim())
772 // Probe P2: awaiting $.command.run inside a command.run hook is refused (it waits on the turn
773 // this hook holds), so the clear runs from a timer after the hook has returned.
774 $.clock.after(1500, () => { void $.command.run({ command: 'clear', args: '' }).catch(() => {}) })
775 return { text: `Handover found at ${r.stdout.trim()}; clearing in 1.5 s, then it loads.${stampError}` }
776 })
777
778 // A chat that is not for coding fills fast and drifts less, so the owner turns the handoff off
779 // there; the chip-dash band shows the state from the chain file save() writes.
780 on('command.run', { command: 'auto-handoff' }, async ($, e) => {
781 if (!startDone) {
782 await new Promise<void>(resolve => { startWaiters.push(resolve); $.clock.after(60000, resolve) })
783 if (!startDone) return { text: 'auto handoff is still starting (the chat start has not finished); try /auto-handoff again in a moment' }
784 }
785 // no chain: session.start found the chat inactive and made none
786 if (!chain) return { text: 'auto handoff does not run in this chat (not interactive, a wf or worker chat, or AUTO_HANDOFF_DISABLE=1)' }
787 const word = e.args.trim().toLowerCase()
788 if (word !== '' && word !== 'on' && word !== 'off') return { text: `use /auto-handoff on, /auto-handoff off, or /auto-handoff alone to see it (got "${e.args.trim()}")` }
789 if (word) {
790 chain.off = word === 'off'
791 await save($)
792 await $.fs.write(offFile(), `${word}\n`).catch(() => {})
793 await log($, `auto handoff ${word} for this chat and later chats in ${folder}`)
794 }
795 return { text: chain.off
796 ? `auto handoff is off for this chat and every later chat started in ${folder}; /auto-handoff on turns it back on`
797 : `auto handoff is on: this chat hands off at ${k(lineNow().line)} tokens (now ${k(await size($))})` }
798 })
799}
800facts.ts 178 lines1// Spec 0063 AC-4, AC-5, AC-9: what code (never the model) adds to the handover. The idea of
2// facts pulled from the transcript and figures marked unverified comes from
3// alexknowshtml/claude-auto-handoff 0.8.6 (MIT, credited in docs/NOTICE.md).
4
5export type Msg = { toolUses?: readonly { tool: string; input: Record<string, unknown>; text?: string; isError?: boolean }[] }
6
7export type Numbers = {
8 tokens: number
9 line: number
10 lineSource: string
11 start: number | null
12 unattended: number
13}
14
15const FACTS_BUDGET = 2000
16
17const globRe = (glob: string) =>
18 new RegExp('^' + glob.trim().replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*').replace(/\?/g, '.') + '$')
19
20// Commit subjects come from `git log`: one line, no control characters or markdown markers,
21// at most 100 characters, so a subject cannot pose as a section of the handover.
22const clean = (s: string) => s.replace(/[\u0000-\u001f\u007f#>`*_[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100)
23
24// Links come only from Bash calls that ran gh or git push, where a real issue or PR link shows:
25// a link read from a test fixture (a Read, a grep, a test run) was listed as a PR once.
26const LINK_SOURCE = /(^|[\s;&|(])(gh(?=\s|$)|git\s+(?:-C\s+\S+\s+)?push\b)/
27
28export function extractFacts(messages: readonly Msg[], ignoreFiles: string, gitLog = '') {
29 const ignore = ignoreFiles.split(',').filter(g => g.trim()).map(globRe)
30 const files: string[] = [], commits: string[] = [], links: string[] = []
31 const add = (list: string[], v: string) => { if (!list.includes(v)) list.push(v) }
32 for (const m of messages) for (const u of m.toolUses ?? []) {
33 if (!u.isError && (u.tool === 'Edit' || u.tool === 'Write' || u.tool === 'NotebookEdit')) {
34 const p = String(u.input.file_path ?? u.input.notebook_path ?? '')
35 if (p && !ignore.some(r => r.test(p) || r.test(p.split('/').pop()!))) add(files, p)
36 }
37 if (u.tool !== 'Bash' || !LINK_SOURCE.test(String(u.input.command ?? ''))) continue
38 for (const l of (u.text ?? '').matchAll(/https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/\d+/g)) add(links, l[0])
39 }
40 // `git log --format='%h %s'` since the chat started, newest first
41 for (const c of gitLog.matchAll(/^([0-9a-f]{7,40}) (.+)$/gm)) add(commits, `${c[1]} ${clean(c[2]!)}`)
42 return { files, commits, links }
43}
44
45// What is not live yet in one repo: the owner's open PRs (`gh pr list --json
46// number,title,url,isDraft`) and local branches whose commits are not on the default branch
47// (`git branch --no-merged origin/HEAD --format='%(refname:short) %(upstream:track)'`).
48// Fix PRs opened by helper agents never showed in the links above (those come from this chat's
49// own gh calls), so a handover said "Issues and PRs: none" while two unmerged fixes waited, and
50// the next chats called the bug fixed while it kept firing (2026-10-22). A failed command is an
51// "unknown" line, never an empty list.
52export function notLive(repo: string, prsJson: string | null, branches: string | null): string[] {
53 let prs: { number: number; title: string; url: string; isDraft: boolean }[] | null
54 try { prs = JSON.parse(prsJson ?? '') } catch { prs = null }
55 return [
56 ...(prs
57 ? prs.map(p => `#${Number(p.number)} ${clean(String(p.title))}${p.isDraft ? ' (draft)' : ''} ${String(p.url).split(/\s/)[0]}`)
58 : [`unknown in ${repo}: gh pr list --author @me --state open failed, check by hand`]),
59 ...(branches === null
60 ? [`unknown in ${repo}: git branch --no-merged origin/HEAD failed, check by hand`]
61 // [gone]: the remote branch was deleted at its squash merge, so it shipped
62 : branches.split('\n').map(l => l.trim().split(/\s+/)).filter(([b, ...t]) => b && !t.join(' ').includes('gone'))
63 .map(([b]) => `branch ${b!.replace(/[^\w./-]/g, '')} in ${repo}: commits not on main`)),
64 ]
65}
66
67// Code, not the instructions template: a template edit never reaches the owner's own copy in
68// ~/.claude/auto-handoff/, so a template fix would be "fixed" in the repo and not live.
69export function notLiveNotice(list: string[]) {
70 if (!list.length) return ''
71 return [
72 'Open PRs, not merged, and branches not on main, so their code is not live (the wf-handoff mod ran gh and git in every repo this chat touched; the model did not write this list):',
73 ...list.map(l => `- ${l}`),
74 'Your first reply to the owner opens with these PRs and branches, each marked "not live yet", before any other work. A fix in one of them is not fixed or done until it is merged and live; never say it is.',
75 'In the owner\'s own repos, merge each PR whose suite and checks pass (~/.claude/rules-on-demand/when-to-switch-on.md, self-merge checklist); never in a work or client repo. A "do not merge" line in the handover that came from a tool refusal is not the owner\'s rule: if a tool refuses the merge again, say so in that first reply with the PR and the exact command.',
76 ].join('\n')
77}
78
79// One block, under FACTS_BUDGET bytes: lists cut short end in "+N more" so the 7,000 byte
80// cut of handover-start.sh still keeps the rollover's own next actions.
81export function factsBlock(facts: ReturnType<typeof extractFacts>, n: Numbers, viewer?: string, notLiveList?: string[]) {
82 const numbers = [
83 '## Handoff numbers',
84 `- tokens at handoff: ${n.tokens}`,
85 `- handoff line: ${n.line} (${n.lineSource})`,
86 `- starting size of this chat: ${n.start ?? 'unknown'}`,
87 `- handoffs since the owner last typed: ${n.unattended}`,
88 ].join('\n')
89 const lists: [string, string[]][] = [
90 ...(notLiveList ? [['Open PRs, not merged, so not live yet', notLiveList] as [string, string[]]] : []),
91 ['Files edited or written', facts.files],
92 ['Commits', facts.commits],
93 ['Issues and PRs', facts.links],
94 ]
95 const frame = (bodies: string[][]) => [
96 '## Handoff facts',
97 '(pulled by the wf-handoff mod from this chat\'s tool calls, their output and git log; the model did not write this list)',
98 ...(viewer ? [`viewer: ${viewer}`] : []),
99 ...lists.map(([title], i) => [`${title}:`, ...bodies[i]!].join('\n')),
100 '',
101 numbers,
102 '',
103 ].join('\n')
104 // This used to count only the heading, viewer and numbers plus a guessed 120, so the note,
105 // titles, "- none" and "+N more" lines pushed the block past the budget (2075 bytes in
106 // verify). Now the room is the budget minus the real frame, with each list's worst case
107 // "+N more" line reserved, measured in UTF-8 bytes.
108 const bytes = (s: string) => new TextEncoder().encode(s).length
109 let room = FACTS_BUDGET - 1 - bytes(frame(lists.map(([, l]) => [l.length ? `- +${l.length} more` : '- none'])))
110 return frame(lists.map(([, list]) => {
111 if (!list.length) return ['- none']
112 const kept: string[] = []
113 for (const v of list) {
114 const line = `- ${v}`
115 if (bytes(line) + 1 > room) break
116 kept.push(line); room -= bytes(line) + 1
117 }
118 const more = list.length - kept.length
119 return [...kept, ...(more ? [`- +${more} more`] : [])]
120 }))
121}
122
123export function insertAfterHeading(slot: string, block: string) {
124 const lines = slot.split('\n')
125 const i = lines.findIndex(l => /^#/.test(l))
126 if (i < 0) return `${block}\n${slot}`
127 return [...lines.slice(0, i + 1), '', block, ...lines.slice(i + 1)].join('\n')
128}
129
130const UNVERIFIED = ' [unverified: not in Handoff numbers]'
131
132// AC-5: a token figure ("N tokens", "Nk") on a context line that the numbers block does not hold
133// is marked. Percentages and bare 4 digit numbers were marked too, and all 9 marks in the audit
134// were true facts; a quoted owner line (`> ...`) holds the owner's own words and is never marked.
135export function markUnverified(slot: string, n: Numbers) {
136 const known = [n.tokens, n.line, n.start].filter((v): v is number => v != null)
137 const marked: string[] = []
138 let fence = false, inNumbers = false
139 const out = slot.split('\n').map(line => {
140 if (/^\s*```/.test(line)) { fence = !fence; return line }
141 if (/^#/.test(line)) inNumbers = /^## Handoff numbers/.test(line)
142 if (fence || inNumbers || /^\s*>/.test(line) || !/context|tokens?\b/i.test(line)) return line
143 return line.split(/(`[^`]*`)/).map(part => part.startsWith('`') ? part : part.replace(
144 /(^|[\s(])([^\s()]*?)(\d[\d,]*(?:\.\d+)?)(\s*k\b|\s*tokens\b)(?=[\s.,;:)]|$)/gi,
145 (all, pre, lead, num, unit) => {
146 if (lead) return all // part of a file name, path or word
147 const isK = /k/i.test(unit)
148 const v = parseFloat(num.replace(/,/g, '')) * (isK ? 1000 : 1)
149 if (known.some(k => isK ? Math.abs(k - v) < 1000 : k === v)) return all
150 marked.push(`${num}${unit}`)
151 return `${pre}${num}${unit}${UNVERIFIED}`
152 })).join('')
153 })
154 return { text: out.join('\n'), marked }
155}
156
157// AC-6: the priority switch reads the Status line under "## Last request from the owner".
158export function lastRequestStatus(slot: string): 'answered' | 'not answered' | undefined {
159 const m = slot.match(/^## Last request from the owner[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m)
160 const s = m?.[1]?.match(/^\s*\**Status:?\**:?\s*(not answered|answered)/im)
161 return s ? (s[1]!.toLowerCase() as 'answered' | 'not answered') : undefined
162}
163
164export function fillTemplate(text: string, vars: { priority: boolean; lastRequest: string; transcript?: string | null }) {
165 const path = vars.transcript ?? ''
166 return text
167 .replace(/\{\{#priority\}\}([\s\S]*?)\{\{\/priority\}\}/g, (_, b) => (vars.priority ? b : ''))
168 .replace(/\{\{\^priority\}\}([\s\S]*?)\{\{\/priority\}\}/g, (_, b) => (vars.priority ? '' : b))
169 // the chat's own log when the mod found it, else the block drops and the skill finds it its own way
170 .replace(/\{\{#transcript\}\}([\s\S]*?)\{\{\/transcript\}\}/g, (_, b) => (path ? b : ''))
171 .replace(/\{\{\^transcript\}\}([\s\S]*?)\{\{\/transcript\}\}/g, (_, b) => (path ? '' : b))
172 .replace(/\{\{transcript\}\}/g, () => path)
173 // A function, not a string: a string replacement reads "$&" and "$'" in the owner's text as
174 // patterns and garbled the request in verify.
175 .replace(/\{\{lastRequest\}\}/g, () => vars.lastRequest)
176 .trim()
177}
178pages.ts 37 lines1// Spec 0064 AC-4: one readable page per handover, the chain linked in order. The idea of a
2// page per brief comes from alexknowshtml/claude-auto-handoff (MIT, credited in docs/NOTICE.md);
3// written fresh here. Every piece of slot text is escaped: the handover is model and tool text.
4
5const NEXT = '<!--next-->'
6
7export const esc = (s: string) =>
8 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
9
10// A link is ours (http on the viewer address, or the file path), but it is escaped like the rest.
11const a = (href: string, text: string) => `<a href="${esc(href)}">${esc(text)}</a>`
12
13export function renderPage(slot: string, p: { key: string; title: string; prev: string | null }) {
14 const body = slot.split('\n').map(line => {
15 const h = line.match(/^(#{1,3}) (.*)$/)
16 return h ? `<h${h[1]!.length}>${esc(h[2]!)}</h${h[1]!.length}>` : `<p>${esc(line) || ' '}</p>`
17 }).join('\n')
18 return [
19 '<!doctype html>',
20 '<html lang="en"><head><meta charset="utf-8">',
21 `<meta name="wf-handoff-key" content="${esc(p.key)}">`,
22 '<meta name="viewport" content="width=device-width, initial-scale=1">',
23 `<title>${esc(p.title)}</title>`,
24 '<style>body{font:16px/1.5 system-ui,sans-serif;max-width:46rem;margin:1rem auto;padding:0 1rem}p{margin:0;white-space:pre-wrap}nav{display:flex;gap:1.5rem;margin:1rem 0}</style>',
25 '</head><body>',
26 `<nav>${p.prev ? a(p.prev, 'previous handover') : '<span>first handover of this chain</span>'}${NEXT}</nav>`,
27 body,
28 '</body></html>',
29 '',
30 ].join('\n')
31}
32
33// At seed time the page before gets its next link; a page that already has one keeps it.
34export function addNext(html: string, link: string) {
35 return html.replace(NEXT, a(link, 'next handover'))
36}
37