SLOPSHOPPER

wf-handoff

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…

guardcommandtoaststatuspromptprocess
v0.4.0NOASSERTIONupdated 2026-10-23Gartner24/claude-config/claude-home/mods/wf-handoff
A shopper browsing a rack in a slop shop
Preview could not run: harness timed out after 15s
README

claude-config

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.

Where things live

Each kind of file has one source of truth. The copy that wins is the one you edit; the other is overwritten.

KindRepo folderLive pathSource of truthWho writes itWhat moves it
Copied (CLAUDE.md, settings*.json, agents/, rules/, rules-on-demand/, MAP.md, your hooks and scripts)claude-home/~/.claude/<same path>live winsyou, in ~/.claudetools/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 reporepo winsyou, in the repotools/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 patchtheir 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 retiredrepo winsnobody; kept as historynothing
Repo toolstools/bin/, tools/scripts/none (a few are linked into ~/.local/bin)repo winsyou, in the reponothing
Frameworks (gstack, jjstack, ECC commands, the JSM skills, plugins)named in tools/config/manifest.jsontheir own folders in ~/.claudetheir installer winstheir installertheir own installer; never copied here
Private (names, work rules, settings fragments, private skills and patches)none~/.claude/private/live onlyyounothing; never in any repo
Claude Code state (projects/, sessions/, history.jsonl, caches)none~/.claude/...Claude Code onlyClaude Codenothing; 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).

One session, start to finish

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
  • User memory loads in every session. Project memory loads only in that folder.
  • A skill is a folder with a SKILL.md. Yours are linked from claude-home/skills/; the rest come from frameworks and downloads, all listed in docs/guides/SKILLS.md.
  • A hook is a script Claude Code runs on an event; it can block a tool call. See docs/guides/HOOKS.md.
  • Private files hold work and client names. tools/bin/sync.sh and the commit hooks refuse any of them reaching this public repo.

Guides

GuideKindWhat it covers
SETUP.mdhow toInstall, restore on a new machine, frameworks, downloaded skills, plugins, MCP servers, keeping it current
DAILY-USE.mdhow toThe workflow chain, /claude-config, picking a skill, and the retired wf commands
SKILLS.mdreferenceEvery skill with its source and license, plus the routing table (generated)
HOOKS.mdreferenceEach hook script, where it comes from, the settings it relies on
DESIGN-PIPELINE.mdhow toSetting 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).

Rules earned the hard way

Each rule below cost something on a real project.

RuleWhereWhy it exists
An absent answer rendered as a definite oneclaude-home/rules/ecc/common/code-review.mdA 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 secondclaude-home/rules/ecc/common/testing.mdBug fixes skip watching the test fail, so break the source, see the test go red, restore
A test that reimplements the logic passes foreverclaude-home/rules/ecc/common/testing.mdA test with a local copy of the behaviour stays green when the real fix is reverted
A mock weaker than the real module passes anythingclaude-home/rules/ecc/common/testing.mdA mock missing one field hid a control that could never render
Shell disciplineclaude-home/CLAUDE.mdA stale cd looks like a deleted tree; $(...) does not expand in a quoted heredoc
Grep before writing against a symbolclaude-home/CLAUDE.mdA 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.

Stack overview

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

Restore on a new machine

  1. Clone this repo and run tools/bin/install.sh --dry-run, then tools/bin/install.sh.
  2. Install the frameworks, plugins and downloaded skills as SETUP.md says.
  3. Merge tools/config/mcp-config.json into ~/.claude.json.
Source 3 files
hooks/register.ts 800 lines
1import 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}
800
facts.ts 178 lines
1// 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}
178
pages.ts 37 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
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) || '&nbsp;'}</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