SLOPSHOPPER

context-vigil-mod

Test harness only: lets claude plugin test run tests/ against plugin/. Never installed; the marketplace points at plugin/.

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-vigil-mod
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ context-vigil-mod │ ● context-vigil-mod: 📜 A handover is already in progress — one at a time│ 📜 A handover is already in progress — one │ ⏺ Read(src/auth.ts) │ at a time │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /vigil-handover ⎿ context-vigil-mod: 📜 Handing over… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pip-skills

A personal collection of Claude Code skills for serious engineering work — from PR review to architectural auditing. Built for real workflows, shared because they might help yours.

What's in here

Four plugins, each with a distinct purpose:

PluginPurpose
PuritanArchitectural doctrine enforcement — plan patterns, audit code, author rules
TribunalPR review workflow — fetch, triage, validate, action, and resolve GitHub PR comments
email-absolutionHTML email auditing and generation — doctrines, visitation, and absolution
django-inquisitionDjango ORM performance auditor — ~70 heuristics, tier-grouped findings, signal-aware

Installing

Marketplace Installation (Recommended)

  1. Open Claude.ai in your browser
  2. Go to Settings → Plugins
  3. Click Browse marketplace
  4. Search for Puritan, Tribunal, email-absolution, or django-inquisition
  5. Click Install
  6. The skills are now available in your chat

Claude Code CLI Installation

You can also install via Claude Code's plugin commands. From within a Claude Code session:

/plugin marketplace add ppryde/pip-skills
/plugin install puritan@ppryde/pip-skills
/plugin install tribunal@ppryde/pip-skills
/plugin install email-absolution@ppryde/pip-skills
/plugin install django-inquisition@ppryde/pip-skills

Or from the terminal CLI:

# Install all plugins
claude plugin install puritan@ppryde/pip-skills
claude plugin install tribunal@ppryde/pip-skills
claude plugin install email-absolution@ppryde/pip-skills
claude plugin install django-inquisition@ppryde/pip-skills

After installing, the skills are available as slash commands:

/puritan:covenant     — architecture planning and pattern selection
/puritan:inquisition  — audit codebase against configured doctrines
/puritan:scriptorium  — author new architecture doctrines

/tribunal:reckoning   — triage and action GitHub PR review comments

/email-absolution:elder      — email planning, Q&A, and config setup
/email-absolution:visitation — audit an email template against doctrine
/email-absolution:scribe     — generate a template correct by construction

/django-inquisition:optimise-orm — audit a Django file or symbol for ORM performance issues

Philosophy

These skills are built around two ideas:

1. Architecture should be codified, not tribal knowledge. Architectural decisions that live only in people's heads — or in an ADR doc nobody reads — don't survive team turnover or code reviews. Puritan turns those decisions into auditable doctrine files that Claude can check your code against, commit by commit.

2. PR review is a workflow, not a scroll. Bot reviewers and human reviewers leave dozens of comments across multiple rounds. Tribunal treats this as a structured workflow: fetch everything, categorise by source and type, validate each comment against the actual current code, propose fixes, apply them with your approval, and resolve the threads.

The Witchfinder

All plugins operate in the voice of a deeply principled but self-aware Puritan inspector. Violations are heresies. Fixes are absolution. The codebase is the sanctum.

The persona is flavour, not a barrier to clarity — every verdict is technically precise and actionable. The Witchfinder is dramatic, not obscure.

Optional: Witchfinder spinner verbs

settings.snippets.json at the repo root contains custom spinner verbs that replace Claude Code's default "Thinking…" messages with in-character Witchfinder flavour while the skills are running.

To use them, copy the file into your Claude Code settings directory:

cp settings.snippets.json ~/.claude/settings.snippets.json

If you already have a settings.snippets.json, merge the spinnerVerbs block into it manually.

The mode: "replace" setting replaces all default spinner verbs with these. If you'd prefer to add them alongside the defaults, change it to "append".

Contributing

Issues and PRs welcome. If you extend a doctrine or add a new one, the Scriptorium skill can help you author it to the required standard.

About

A personal collection of Claude Code skills for serious engineering work — from PR review to architectural auditing. Built for my own workflows, shared because they might help yours.

License

MIT license

Source 16 files
plugin/hooks/register.tsx 1446 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Activity, Awaiting, EventKind, EventRecord, Git, Latch, Mode, Pending, PhoneFacts, PendingReason, RateLimit, Override, Settings, StepId } from '../types'
4import { ASK, DEFAULT_OVERRIDES, checkOverrides, fromModelThresholds, formatKey, formatOverride, formatWindow, overridesJson, ordered, parseKey, parseOverridesArgs, patternFor, removeOverride, resolve, sameKey, setOverride } from '../core/overrides'
5import type { Resolved } from '../core/overrides'
6import { COMMANDS, TOOL, TOOL_FULL, classicSessionPath, configRoot, eventsPath, handoverPath, overridesPath } from '../core/name'
7import { DEFAULTS, PENDING_KEEP_MS, PENDING_PREFIX, STORE_KEY, loadSettings, pendingKey } from '../core/settings'
8import { EMPTY_ACTIVITY, WORKING_MS, armed, classifyOrigin, mode, onPhone, record, transition } from '../core/arming'
9import type { Signal } from '../core/arming'
10import { appendLine, dayKey, makeRecord } from '../core/eventlog'
11import { COALESCE_MS, GIT_ARGV, GIT_DIR_ARGV, parseGit, touchesGit, watchPaths } from '../core/git'
12import { INPUT_SCHEMA, TOOL_DESCRIPTION, fresh, grownEnough, injectText, instructionText, isHandoverRequest, limitResumeText, mentionsHandover, nextThreshold, parseFields, renderHandover, resumeText, reusable } from '../core/handover'
13import { HOME_VARS_CHECKED } from '../core/home'
14import { TAIL_CMD, type CacheTtl, cacheLinesFromText, parseWrites, transcriptPathFor, ttlFromWrites } from '../core/cache-ttl'
15import { TTL_1H, fireAt, holdOnReturn, rearm, shouldFire } from '../core/last-light'
16import { clearGate, needsRcHint } from '../core/surfaces'
17import { applyAnswers, extractAnswers, isStep, nextCard, questionFor } from '../core/setup'
18import { RESUME_DELAY_MS, earlyStopDue, formatHHMM, latchCleared, latchFromMeasure, latchFromStopFailure, nextHop } from '../core/limits'
19import { classicHooksInstalled } from '../core/interlock'
20import { V } from '../core/voice'
21import type { WaitReason } from '../core/voice'
22
23// The effectful shell: the ONLY file that touches `$`. Decisions live in ../core.
24
25// $.state is per session and every /clear wipes it (PROBES §9): these atoms hold only what a
26// fresh session may forget.
27const modeA = atom({ plugin: 'context-vigil-mod', key: 'mode' } as const, 'idle')
28const contextA = atom({ plugin: 'context-vigil-mod', key: 'contextPct' } as const, null)
29const windowA = atom({ plugin: 'context-vigil-mod', key: 'contextWindow' } as const, null as number | null)
30const modelA = atom({ plugin: 'context-vigil-mod', key: 'contextModel' } as const, null as string | null)
31const baselineA = atom({ plugin: 'context-vigil-mod', key: 'baselinePct' } as const, null as number | null)
32const lastNudgedA = atom({ plugin: 'context-vigil-mod', key: 'lastNudged' } as const, null)
33const barShownA = atom({ plugin: 'context-vigil-mod', key: 'barShown' } as const, false)
34const barDismissedA = atom({ plugin: 'context-vigil-mod', key: 'barDismissed' } as const, false)
35const pendingA = atom({ plugin: 'context-vigil-mod', key: 'pending' } as const, null)
36const countdownA = atom({ plugin: 'context-vigil-mod', key: 'countdownEndsAt' } as const, null)
37const transcriptA = atom({ plugin: 'context-vigil-mod', key: 'transcriptPath' } as const, null as string | null)
38// Information only (PROBES §11): what the session's cache is. The fire-time check stays the gate.
39const cacheTtlA = atom({ plugin: 'context-vigil-mod', key: 'cacheTtl' } as const, 'unknown' as CacheTtl)
40const ttlReadA = atom({ plugin: 'context-vigil-mod', key: 'ttlRead' } as const, false)
41const ttlInfoDismissedA = atom({ plugin: 'context-vigil-mod', key: 'ttlInfoDismissed' } as const, false)
42// Did the person's latest message mention a handover at all? Lets the model's tool call count as asked for.
43const handoverMentionedA = atom({ plugin: 'context-vigil-mod', key: 'handoverMentioned' } as const, false)
44const lastApiA = atom({ plugin: 'context-vigil-mod', key: 'lastApiAt' } as const, null)
45const awaitingA = atom({ plugin: 'context-vigil-mod', key: 'awaiting' } as const, null as Awaiting | null)
46const deferredA = atom({ plugin: 'context-vigil-mod', key: 'deferred' } as const, null as Awaiting | null)
47const handoverCountA = atom({ plugin: 'context-vigil-mod', key: 'handoverCount' } as const, 0)
48// The phone facts of `activity`, written through so a hot reload (which keeps $.state) restores
49// them; a clear wipes this, so the clear branch writes it again from the module copy.
50// The prompts held while the return question is open (R1-19, R2-11). In $.state so a hot reload
51// that kills the module (and its ask's closure) still knows what is owed; a clear wipes it.
52// R3-02: the held text also lives here, because a /clear or /resume wipes $.state while the ask stays open.
53let returnHeldMirror: string[] | null = null
54const returnHeldA = atom({ plugin: 'context-vigil-mod', key: 'returnHeld' } as const, null as string[] | null)
55const phoneA = atom({ plugin: 'context-vigil-mod', key: 'phoneFacts' } as const, null as PhoneFacts | null)
56
57// An account fact in $.store: the limit latch, shared by every session of the account.
58const LATCH_KEY = 'latch'
59
60// Facts about the person and the install that a /clear must not forget: module variables
61// survive it (same process). A new session or a hot reload (bindSession) starts them fresh,
62// counting that moment as the person being here for one idle window.
63let activity: Activity = EMPTY_ACTIVITY
64let lastLightArmed = false
65let standDown = false
66// Per process, never reset: each running session owes its own early stop before a hard limit,
67// and a clear (new session id) must not re-fire one for the same window. A reload may re-fire once.
68let firedEarlyStops: string[] = []
69// The limit resume waiting on its handover file; the tool call fills `path` when it is written.
70let limitResume: { path: string | null; at: number; stoppedAt: number; gen: number } | null = null
71let resumeChain: { cancel: () => void } | null = null
72let resumeGen = 0
73// The timer that lifts the account latch for this process: one, replaced never stacked (R2-04).
74let latchTimer: { cancel: () => void } | null = null
75// The RC hint is shown once per session: a clear wipes $.state, so it cannot live there.
76// A new session (bindSession) resets it; resetCaches leaves it alone.
77let rcHinted = false
78
79// Module caches: rebuilt at session.start / after a hot reload.
80let root: string | null = null   // null: no config dir known, so nothing is written (configRoot)
81let session = 'unknown'
82let cwd = ''
83let settings: Settings = DEFAULTS
84let git: Git = { branch: null, dirty: [] }
85let gitTimer: { cancel: () => void } | null = null
86const edited = new Set<string>()
87let dayText: Record<string, string> = {}
88// lastApiAt, mirrored out of $.state: a /clear wipes the atom, and the clear branch must still
89// know whether turns have run since a parked handover was written (R1-10).
90let lastApiMirror: number | null = null
91let clearParked = false
92let unattendedClear = false
93let lastWait: WaitReason | null = null
94let retryTimer: { cancel: () => void } | null = null
95// A /clear handed to the engine and not yet run: it is queued until the session is idle (R2-10),
96// so a second one must not follow it. Cleared when the command settles.
97let clearInFlight = false
98let setupRun: { only: string | undefined; asked: StepId[] } | null = null
99let lastLightTimer: { cancel: () => void } | null = null
100let countdownTick: { cancel: () => void } | null = null
101
102// A timer-driven job that threw: logged, never an unhandled rejection (R2-15).
103async function timerFailed($: EngineInterface, job: string, err: unknown) {
104  await log($, 'guard.wait', { reason: 'timer-error', job, error: String(err) }).catch(() => {})
105}
106
107async function nowMs($: EngineInterface): Promise<number> {
108  return $.clock.now()
109}
110
111// Appends are serialised through one chain, so a day file's first read-then-append cannot be raced
112// by a second hook (R1-15). A day file that exists but cannot be read is never overwritten.
113let logChain: Promise<void> = Promise.resolve()
114const logWriteFailed = new Set<string>()
115
116async function appendToDayFile($: EngineInterface, path: string, rec: EventRecord) {
117  if (dayText[path] === undefined) {
118    try {
119      dayText[path] = String(await $.fs.read(path))
120    } catch (err) {
121      if (!/ENOENT|no such file|not found/i.test(String((err as { code?: string; message?: string })?.code ?? '') + String((err as Error)?.message ?? err))) {
122        try { await $.ui.log(`context-vigil-mod: event log skipped, ${path} is unreadable: ${String(err)}`) } catch { /* nowhere left to say it */ }
123        return
124      }
125      dayText[path] = ''
126    }
127  }
128  dayText[path] = appendLine(dayText[path] ?? '', rec)
129  await $.fs.write(path, dayText[path] ?? '').catch(async (err: unknown) => {
130    // A log that cannot be written must not be silent, and must not become a loop: say it once per path.
131    if (logWriteFailed.has(path)) return
132    logWriteFailed.add(path)
133    try { await $.ui.log(`context-vigil-mod: event log not written, ${path}: ${String(err)}`) } catch { /* nowhere left to say it */ }
134  })
135}
136
137async function log($: EngineInterface, kind: EventKind, fields: Record<string, unknown> = {}) {
138  if (!root) return
139  const now = await nowMs($)
140  const path = eventsPath(root, dayKey(now), session)
141  const rec = makeRecord(now, session, kind, fields)
142  const turn = logChain.then(() => appendToDayFile($, path, rec))
143  logChain = turn.catch(() => {})
144  await turn
145}
146
147// PROBES.md §1 decides the channel; both are sent until it says otherwise.
148async function notify($: EngineInterface, text: string) {
149  await Promise.all([$.ui.toast(text), $.ui.log(text)]).catch(() => {})
150}
151
152// Every plugin prompt goes through here: from a timer, never awaited by the hook the turn waits on.
153function submitSoon($: EngineInterface, prompt: { text: string; asUser?: true }, delayMs = 0, onSent?: () => void, onFailed?: () => void) {
154  $.clock.after(delayMs, () => { void $.prompt.submit(prompt).then(() => onSent?.(), () => onFailed?.()) })
155}
156
157// A prompt sent right after a clear can be refused while the session is still starting (slow SessionStart
158// hooks). Retry with backoff -- the first attempt after `firstDelayMs`, then 1, 2, 4 and 8 s later -- and
159// give up only after the last. A success ends the chain (never two submits); a human prompt or a newer
160// chain cancels it: the person's own words win. The engine offers no readiness signal, hence the backoff.
161const RETRY_AFTER_MS = [1000, 2000, 4000, 8000]
162let submitGen = 0   // bumped by a human prompt and by every new chain
163
164function sleepMs($: EngineInterface, ms: number): Promise<void> {
165  return new Promise(resolve => { $.clock.after(ms, () => resolve()) })
166}
167
168async function submitWithRetry($: EngineInterface, prompt: { text: string; asUser?: true }, firstDelayMs: number): Promise<'sent' | 'cancelled' | 'failed'> {
169  const gen = ++submitGen
170  const delays = [firstDelayMs, ...RETRY_AFTER_MS]
171  for (let attempt = 1; attempt <= delays.length; attempt++) {
172    await sleepMs($, delays[attempt - 1]!)
173    if (gen !== submitGen) {
174      await log($, 'guard.wait', { reason: 'resume-cancelled', attempt })
175      return 'cancelled'
176    }
177    try {
178      await $.prompt.submit(prompt)
179    } catch (err) {
180      await log($, 'guard.wait', { reason: 'resume-retry', attempt, error: String(err) })
181      continue
182    }
183    await log($, 'resume.sent', { attempt })
184    return 'sent'
185  }
186  return 'failed'
187}
188
189function resumeSoon($: EngineInterface, prompt: { text: string; asUser?: true }, path: string | null, held: string | null) {
190  void submitWithRetry($, prompt, 500)
191    .then(outcome => (outcome === 'failed' ? resumeFailed($, path, held) : undefined))
192    .catch(err => timerFailed($, 'resume-submit', err))
193}
194
195// A resume or held-prompt submit that is refused: say so, with the handover and the held text.
196async function resumeFailed($: EngineInterface, path: string | null, held: string | null) {
197  await notify($, V.resumeFailed(path, held))
198  await log($, 'guard.wait', { reason: 'resume-rejected', path, held: held !== null })
199}
200
201// PROBES.md §7: a mod never sees its own submit in prompt.submit, so `started` is marked here.
202function submitInstruction($: EngineInterface, reason: PendingReason) {
203  submitSoon($, { text: instructionText(reason) }, 0, () => {
204    void update($, awaitingA, a => (a ? { ...a, started: true } : a))
205  }, () => {
206    // A rejected submit must not leave the handover waiting for a turn that never comes.
207    void (async () => {
208      await update($, awaitingA, () => null)
209      await notify($, V.handoverFailed)
210      await log($, 'guard.wait', { reason: 'submit-rejected' })
211    })()
212  })
213}
214
215async function observe($: EngineInterface, signal: Signal) {
216  const now = await nowMs($)
217  // Every change is computed from the value it replaces, so overlapping observers cannot erase each other.
218  activity = record(activity, signal)
219  if (signal.kind === 'prompt' && classifyOrigin(signal.origin) === 'human') await savePhoneFacts($)
220  // R1-09: an explicit act from the person cancels a handover that waited on the latch.
221  if ((signal.kind === 'human-command' || (signal.kind === 'prompt' && classifyOrigin(signal.origin) === 'human')) && (await read($, deferredA))) {
222    const dropped = await read($, deferredA)
223    await update($, deferredA, () => null)
224    await notify($, V.deferredDropped)
225    await log($, 'guard.wait', { reason: 'deferred-dropped', deferred: dropped?.reason })
226  }
227  // R2-01: the same act turns a clear the latch parked into an offer; the handover stays pending.
228  if (clearParked && lastWait === 'latched' && (signal.kind === 'human-command' || (signal.kind === 'prompt' && classifyOrigin(signal.origin) === 'human'))) {
229    const parked = await read($, pendingA)
230    if (parked) await dropParkedClear($, parked, 'human')
231  }
232  // Spec §2: a non-empty draft in the terminal box is you being here.
233  if (signal.kind === 'agent-step' && (await $.prompt.read()).text.trim()) activity = record(activity, { kind: 'edit', at: now })
234  const act = activity   // this observation's view: later awaits may move `activity` on
235  const next = mode(act, now, settings)
236  let prev: string | undefined
237  await update($, modeA, p => { prev = p; return next })
238  if (prev === undefined || prev === next) return
239  const t = transition(prev as Mode, next)
240  if (t && settings.auto) {
241    await log($, t, { from: prev, to: next, idleMs: act.lastHumanAt === null ? null : now - act.lastHumanAt, origin: act.lastHumanOrigin })
242  }
243  if (t === 'arm') await maybeHintRc($)
244}
245
246// A clear the latch parked is no longer going to run: it is offered instead (spec §5). Pending and
247// file stay; `clearParked` stays so the latch's lift leaves it alone. Never silent.
248async function dropParkedClear($: EngineInterface, pending: Pending, cause: 'human' | 'auto-off' | 'attended' | 'stale') {
249  lastWait = null
250  await notify($, V.pendingOffer(pending.path))
251  await log($, 'guard.wait', { reason: 'parked-dropped', cause })
252}
253
254// Setup asks through $.ui.ask, one step at a time: no prompt is submitted and nothing
255// reaches the model. The dialog does not pass through this mod's own tool.call hook
256// (PROBES.md §6), so answers are applied here, not there.
257async function startSetup($: EngineInterface, only?: string) {
258  if (only !== undefined && !isStep(only)) { await notify($, V.setupUsage); return }
259  const run = { only, asked: [] as StepId[] }
260  setupRun = run
261  if (!nextCard(settings, [], only).length) { setupRun = null; await notify($, V.setupSaved); return }
262  // After the command has replied: the dialogs follow it rather than holding it open.
263  $.clock.after(0, () => { void askSteps($, run).catch(err => timerFailed($, 'setup', err)) })
264}
265
266async function askSteps($: EngineInterface, run: NonNullable<typeof setupRun>) {
267  for (;;) {
268    // A clear can land inside any await (a store write included): never ask into the new session.
269    if (setupRun !== run) return
270    const id = nextCard(settings, run.asked, run.only)[0]
271    if (id === undefined) break
272    let explain = false
273    for (;;) {
274      const q = questionFor(id, explain)
275      const answer = await $.ui
276        .ask(q.question, { header: q.header, options: q.options, ...(q.multiSelect ? { multiSelect: true as const } : {}) })
277        .then(a => ({ a }), (err: unknown) => ({ err }))
278      // A clear or another /vigil-setup took over while the dialog was open: its answer is not ours.
279      if (setupRun !== run) return
280      // A dismissal and a failed dialog reject alike (no reason is typed): either way stop, log
281      // why, and say so, so a failure is never silent. What was answered is already saved.
282      if ('err' in answer) {
283        setupRun = null
284        await log($, 'setup', { steps: [id], stopped: String(answer.err).slice(0, 200) })
285        await notify($, V.setupStopped)
286        return
287      }
288      await observe($, { kind: 'human-command', at: await nowMs($) })
289      await reloadSettings($)
290      const applied = applyAnswers(settings, [{ step: id, answer: answer.a }])
291      if (setupRun !== run) return
292      await log($, 'setup', { steps: [id], retell: applied.retell })
293      // The log write is an await too: a newer /vigil-setup or a clear may have taken over during it.
294      if (setupRun !== run) return
295      if (applied.retell.length) { explain = true; continue }
296      settings = applied.settings
297      await $.store.set(STORE_KEY, settings)
298      if (id === 'rc') await log($, 'rc.answer', { answer: settings.rcAutoClear })
299      break
300    }
301    run.asked.push(id)
302  }
303  if (setupRun !== run) return
304  setupRun = null
305  await notify($, V.setupSaved)
306  // The person who just answered is here: a clear parked while they were away is offered, never run.
307  const parked = clearParked ? await read($, pendingA) : null
308  if (parked) await notify($, V.pendingOffer(parked.path))
309}
310
311// Never a dialog mid-run (WF-262): the question lives in /vigil-setup. Unanswered follows auto mode
312// (clears on the phone, safeguards kept); this says so once per session, and only when the stand-down and the latch leave the mod free to speak.
313async function maybeHintRc($: EngineInterface) {
314  if (!needsRcHint(onPhone(activity), settings.rcAutoClear, settings.auto)) return
315  if (rcHinted || standDown || (await readLatch($))) return
316  rcHinted = true
317  await notify($, V.rcHint)
318  await log($, 'rc.answer', { hinted: true })
319}
320
321async function refreshGit($: EngineInterface) {
322  gitTimer = null
323  const run = (argv: readonly string[]) =>
324    $.process.run(argv, { cwd }).then(r => ({ exitCode: r.exitCode, stdout: r.stdout })).catch(() => ({ exitCode: 1, stdout: '' }))
325  const [b, s] = await Promise.all([run(GIT_ARGV.branch), run(GIT_ARGV.status)])
326  git = parseGit(b, s)
327}
328
329function scheduleGit($: EngineInterface) {
330  if (gitTimer) return
331  gitTimer = $.clock.after(COALESCE_MS, () => { void refreshGit($) })
332}
333
334// RESET: every module cache and timer (pre-flight F3), in one place — a hot reload, a reused
335// module or a /clear must start clean. Tasks 13–15 add their own module variables here.
336function resetCaches() {
337  gitTimer?.cancel()
338  gitTimer = null
339  git = { branch: null, dirty: [] }
340  edited.clear()
341  dayText = {}
342  retryTimer?.cancel()
343  retryTimer = null
344  clearParked = false
345  unattendedClear = false
346  lastWait = null
347  lastLightTimer?.cancel()
348  lastLightTimer = null
349  setupRun = null
350  overridesRun = null
351  ambiguityTold.clear()
352  countdownTick?.cancel()
353  countdownTick = null
354}
355
356async function savePhoneFacts($: EngineInterface) {
357  const { lastHumanOrigin, lastBridgeAt } = activity
358  await update($, phoneA, () => ({ lastHumanOrigin, lastBridgeAt }))
359}
360
361// overrides.json is the person's to edit: read whenever a threshold is decided, written only when
362// missing (the default override, so it is visible) or by /vigil-overrides. A fault drops only its override,
363// and is told once per distinct file text.
364let overrides: Override[] = DEFAULT_OVERRIDES
365let faultsToldFor: string | null = null
366const ambiguityTold = new Set<string>()
367
368async function loadOverrides($: EngineInterface): Promise<{ faults: string[] }> {
369  if (!root) { overrides = DEFAULT_OVERRIDES; return { faults: [] } }
370  const path = overridesPath(root)
371  const text = await $.fs.read(path).then(t => String(t), () => null)
372  if (text === null) {
373    overrides = DEFAULT_OVERRIDES
374    await $.fs.write(path, overridesJson(DEFAULT_OVERRIDES)).catch(() => {})
375    return { faults: [] }
376  }
377  const checked = checkOverrides(text)
378  // A file that does not parse names no override to drop: keep the last good read (the default
379  // override before any) rather than lose every override to one stray comma.
380  if (!checked.fileFault) overrides = checked.overrides
381  if (checked.faults.length && text !== faultsToldFor) {
382    faultsToldFor = text
383    await notify($, checked.fileFault
384      ? V.overridesFileFault(path, checked.faults[0] ?? '', ordered(overrides).map(o => `  ${formatOverride(o)}`).join('\n') || '  (no overrides)')
385      : V.overridesFaults(path, checked.faults.length, checked.faults.map(f => `  ${f}`).join('\n')))
386  }
387  return { faults: checked.faults }
388}
389
390async function saveOverrides($: EngineInterface, next: Override[]): Promise<boolean> {
391  if (!root) return false
392  const ok = await $.fs.write(overridesPath(root), overridesJson(next)).then(() => true, () => false)
393  if (ok) { overrides = next; await log($, 'setup', { overrides: next }) }
394  return ok
395}
396
397// Overrides 0.1.3 saved in the settings store move to overrides.json once; one already in the file
398// for the same key stands. The store field is dropped only after the file is written.
399async function migrateModelThresholds($: EngineInterface) {
400  const raw = await $.store.get(STORE_KEY)
401  if (!root || !raw || typeof raw !== 'object' || !('modelThresholds' in raw)) return
402  const { overrides: moved, dropped } = fromModelThresholds(raw)
403  const fresh = moved.filter(m => !overrides.some(o => sameKey(o, m)))
404  const kept = moved.filter(m => !fresh.includes(m))
405  if (fresh.length && !(await saveOverrides($, fresh.reduce(setOverride, overrides)))) return
406  const { modelThresholds: _gone, ...rest } = raw as Record<string, unknown>
407  await $.store.set(STORE_KEY, rest)
408  settings = loadSettings(rest)
409  if (moved.length || dropped.length) {
410    await notify($, V.overridesMigrated(overridesPath(root), fresh.map(o => `  ${formatOverride(o)}`).join('\n'), kept.map(o => formatKey(o)).join(', '), dropped.join(', ')))
411  }
412}
413
414async function thresholds($: EngineInterface): Promise<Resolved> {
415  return resolve({ nudgeAt: settings.nudgeAt, step: settings.step, lastLightAt: settings.lastLightAt }, overrides, await read($, modelA), await read($, windowA))
416}
417
418async function tellAmbiguity($: EngineInterface, r: Resolved) {
419  if (!r.ambiguous) return
420  const { model, window, fields } = r.ambiguous
421  const key = `${formatKey(model)}|${formatKey(window)}`
422  if (ambiguityTold.has(key)) return
423  ambiguityTold.add(key)
424  await notify($, V.overridesAmbiguous(formatKey(model), formatKey(window), fields.map(f => `${f} ${r.values[f]}%`).join(', '), formatKey({ ...model, ...window })))
425}
426
427async function overridesReport($: EngineInterface, faults: string[]): Promise<string> {
428  const here = await thresholds($)
429  const model = await read($, modelA)
430  const window = await read($, windowA)
431  const from = (k: 'nudgeAt' | 'step' | 'lastLightAt') => here.from[k] ? formatKey(here.from[k]!) : 'settings'
432  const ambiguous = here.ambiguous ? [`⚠️ ${formatKey(here.ambiguous.model)} and ${formatKey(here.ambiguous.window)} both set ${here.ambiguous.fields.join(', ')}; the window wins`] : []
433  return V.overridesList(
434    root ? overridesPath(root) : '',
435    ordered(overrides).map(o => `  ${formatOverride(o)}`).join('\n'),
436    `nudge ${settings.nudgeAt}%, step ${settings.step}%, last light ${settings.lastLightAt}%`,
437    `${model ?? 'model unknown'} · ${window === null ? 'window not measured yet' : formatWindow(window)}`,
438    `nudge ${here.values.nudgeAt}% (${from('nudgeAt')}), step ${here.values.step}% (${from('step')}), last light ${here.values.lastLightAt}% (${from('lastLightAt')})`,
439    [...ambiguous, ...faults.map(f => `⚠️ ${f} — ignored`)].join('\n'),
440  )
441}
442
443// /vigil-overrides add: an override for the session you are in, asked through $.ui.ask like setup.
444let overridesRun: object | null = null
445
446async function askOverrides($: EngineInterface, run: object) {
447  const ask = async (question: string, header: string, options: string[]) => {
448    const a = await $.ui.ask(question, { header, options }).then(x => String(x), () => null)
449    if (overridesRun !== run) return null
450    // An answer is the person here, as in setup: the idle window starts again.
451    if (a !== null) {
452      await observe($, { kind: 'human-command', at: await nowMs($) })
453      if (overridesRun !== run) return null
454    }
455    return a
456  }
457  const model = await read($, modelA)
458  const window = await read($, windowA)
459  const pattern = model ? patternFor(model) : null
460  const keys: { label: string; key: Pick<Override, 'model' | 'window'> }[] = []
461  if (pattern && window !== null) keys.push({ label: `${pattern} on ${formatWindow(window)}`, key: { model: pattern, window } })
462  if (window !== null) keys.push({ label: `Any model on ${formatWindow(window)}`, key: { window } })
463  if (pattern) keys.push({ label: `${pattern} on any window`, key: { model: pattern } })
464  for (const w of [1_000_000, 200_000]) if (keys.length < 2 && w !== window) keys.push({ label: `Any model on ${formatWindow(w)}`, key: { window: w } })
465  const which = await ask(ASK.key, '🔧 Covers', keys.map(k => k.label))
466  if (which === null) { await notify($, V.overridesStopped); return }
467  const key = keys.find(k => k.label === which)?.key ?? parseKey(which)
468  if (!key) { await notify($, V.overridesBadKey(which)); return }
469  const base = (await thresholds($)).values
470  const pct = (label: string | null, lo: number, hi: number): number | null | undefined => {
471    if (label === null) return undefined
472    if (label.startsWith('Inherit')) return null
473    const n = Number(label.replace(/%.*$/, '').trim())
474    return Number.isInteger(n) && n >= lo && n <= hi ? n : undefined
475  }
476  const nudgeAt = pct(await ask(ASK.nudge(formatKey(key)), '🎚️ Nudge at', [`Inherit (${base.nudgeAt}%)`, '25%', '35%', '50%']), 1, 100)
477  if (nudgeAt === undefined) { await notify($, V.overridesStopped); return }
478  const step = pct(await ask(ASK.step, '📏 Step', [`Inherit (${base.step}%)`, '5%', '10%']), 1, 50)
479  if (step === undefined) { await notify($, V.overridesStopped); return }
480  const lastLightAt = pct(await ask(ASK.lastLight, 'Last light', [`Inherit (${base.lastLightAt}%)`, '25%', '50%']), 1, 100)
481  if (lastLightAt === undefined) { await notify($, V.overridesStopped); return }
482  const override: Override = { ...key, ...(nudgeAt !== null ? { nudgeAt } : {}), ...(step !== null ? { step } : {}), ...(lastLightAt !== null ? { lastLightAt } : {}) }
483  if (nudgeAt === null && step === null && lastLightAt === null) { await notify($, V.overridesNothingSet); return }
484  const { faults } = await loadOverrides($)
485  if (overridesRun !== run) return
486  if (!(await saveOverrides($, setOverride(overrides, override)))) { await notify($, V.overridesUnwritable); return }
487  overridesRun = null
488  await notify($, await overridesReport($, faults))
489}
490
491async function bindSession($: EngineInterface) {
492  resetCaches()
493  // A reload keeps $.state: the phone facts come back so the RC gate still sees the phone.
494  activity = { ...EMPTY_ACTIVITY, ...(await read($, phoneA)), lastHumanAt: await nowMs($) }
495  lastLightArmed = false
496  standDown = false
497  rcHinted = false
498  root = configRoot({
499    CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
500    HOME: await $.env.get('HOME'),
501    USERPROFILE: await $.env.get('USERPROFILE'),
502    HOMEDRIVE: await $.env.get('HOMEDRIVE'),
503    HOMEPATH: await $.env.get('HOMEPATH'),
504  })
505  session = await $.session.id()
506  cwd = await $.session.cwd()
507  settings = loadSettings(await $.store.get(STORE_KEY))
508  await loadOverrides($)
509  await migrateModelThresholds($)
510  lastApiMirror = await read($, lastApiA)
511}
512
513// Settings are an account fact other sessions write too: re-read before a decision that matters
514// (R1-06). One cheap store read; loadSettings fills what is missing.
515async function reloadSettings($: EngineInterface) {
516  settings = loadSettings(await $.store.get(STORE_KEY))
517}
518
519async function checkInterlock($: EngineInterface) {
520  const at = root
521  const text = at ? await $.fs.read(`${at}/settings.json`).then(t => String(t)).catch(() => null) : null
522  const record = at ? await $.fs.exists(classicSessionPath(at, session)).catch(() => false) : false
523  const classic = classicHooksInstalled(text) || record
524  const was = standDown
525  standDown = classic
526  if (classic && !was) {
527    await notify($, V.classicActive)
528    await log($, 'standdown', { settingsHooks: classicHooksInstalled(text), sessionRecord: record })
529  }
530}
531
532async function readLatch($: EngineInterface): Promise<Latch> {
533  return ((await $.store.get(LATCH_KEY)) as Latch | undefined) ?? null
534}
535
536// Per-session state the new session must not inherit. After a clear the wipe already empties
537// these; reset anyway so nothing leans on it.
538async function resetSessionState($: EngineInterface) {
539  await update($, awaitingA, () => null)
540  await update($, deferredA, () => null)
541  await update($, lastNudgedA, () => null)
542  await update($, baselineA, () => null)
543  await update($, barShownA, () => false)
544  await update($, barDismissedA, () => false)
545  await update($, countdownA, () => null)
546  await update($, handoverCountA, () => 0)
547  await update($, cacheTtlA, () => 'unknown')
548  await update($, ttlReadA, () => false)
549  await update($, ttlInfoDismissedA, () => false)
550  await update($, returnHeldA, () => null)
551  // A resume or fork may be a different model and window: never keep the last session's until the
552  // next measure, or /vigil-overrides would list or add for the wrong session.
553  const model = await $.session.model().catch(() => null)
554  const window = (await $.session.usage().catch(() => null))?.context?.window ?? null
555  await update($, modelA, () => model)
556  await update($, windowA, () => window)
557}
558
559async function prunePending($: EngineInterface) {
560  const now = await nowMs($)
561  for (const key of await $.store.keys().catch(() => [] as string[])) {
562    if (!key.startsWith(PENDING_PREFIX)) continue
563    const p = (await $.store.get(key)) as Pending | null | undefined
564    if (!p || now - p.createdAt > PENDING_KEEP_MS) await $.store.delete(key)
565  }
566}
567
568async function savePending($: EngineInterface, p: Pending | null) {
569  const prev = await read($, pendingA)
570  await update($, pendingA, () => p)
571  if (p) await $.store.set(pendingKey(p.session), p)
572  else await $.store.delete(pendingKey(prev?.session ?? session))
573}
574
575// An instruction nobody answered for this long, with the agent idle, is lost (R2-03).
576const AWAITING_LOST_MS = 10 * 60_000
577
578// What startHandover did, so a caller can say it truthfully (R2-14).
579type Started = 'started' | 'reused' | 'covered' | 'standdown' | 'latched' | 'in-flight'
580
581async function startHandover($: EngineInterface, reason: PendingReason, resume: boolean, unattended: boolean = reason === 'threshold'): Promise<Started> {
582  if (standDown) return 'standdown'
583  const now = await nowMs($)
584  // R2-12: a fresh handover is already on disk (one asked for, waiting to clear): an early stop needs
585  // no second one — the limit resume names this file and the waiting clear is left alone.
586  if (reason === 'limit' && reusable(await read($, pendingA), await read($, lastApiA), now)) {
587    await log($, 'guard.wait', { reason: 'limit-covered' })
588    return 'covered'
589  }
590  if (await readLatch($)) {
591    // Spec §5: while latched the mod never submits; Task 14's checkLatch starts it later.
592    await update($, deferredA, () => ({ reason, resume, attempts: 1, started: false, unattended, since: now }))
593    await notify($, V.waiting('latched'))
594    await log($, 'guard.wait', { reason: 'latched', deferred: reason })
595    return 'latched'
596  }
597  // R1-11: one handover in flight at a time; a second instruction would produce a second tool call.
598  // Lost = old AND nothing is running (a queued instruction would have started), so one waiting
599  // behind a long turn is never re-sent; a deleted or altered instruction no longer blocks forever.
600  const inFlight = await read($, awaitingA)
601  if (inFlight) {
602    const idle = activity.lastAgentAt === null || now - activity.lastAgentAt >= WORKING_MS
603    if (now - inFlight.since < AWAITING_LOST_MS || !idle) {
604      await notify($, V.handoverInProgress)
605      await log($, 'guard.wait', { reason: 'handover-in-flight', asked: reason })
606      return 'in-flight'
607    }
608    await update($, awaitingA, () => null)
609    await notify($, V.handoverLost)
610    await log($, 'guard.wait', { reason: 'awaiting-expired', asked: reason })
611  }
612  const pending = await read($, pendingA)
613  if ((reason === 'threshold' || reason === 'request') && reusable(pending, await read($, lastApiA), now)) {
614    // R3-01: the person's /vho wants the resume; a volunteered pending saved resume:false.
615    if (pending && (pending.resume !== resume || (unattended && !pending.unattended))) await savePending($, { ...pending, resume, unattended: pending.unattended || unattended })
616    if (!clearInFlight) scheduleClear($, unattended)
617    return 'reused'
618  }
619  if (pending) await supersedePending($)
620  await update($, awaitingA, () => ({ reason, resume, attempts: 1, started: false, unattended, since: now }))
621  await log($, 'handover.requested', { reason, resume })
622  submitInstruction($, reason)
623  return 'started'
624}
625
626// A stale pending handover gives way to the fresh one: its clear stops waiting and a /clear
627// in between injects nothing old. The file stays on disk.
628async function supersedePending($: EngineInterface) {
629  retryTimer?.cancel()
630  retryTimer = null
631  clearParked = false
632  lastWait = null
633  await setCountdown($, null)
634  await savePending($, null)
635}
636
637// The band draws the seconds left from the clock, which never redraws it: tick while it runs.
638async function setCountdown($: EngineInterface, endsAt: number | null) {
639  await update($, countdownA, () => endsAt)
640  countdownTick?.cancel()
641  countdownTick = endsAt === null ? null : $.clock.every(1000, () => { $.ui.invalidate('ui.render') })
642}
643
644function scheduleClear($: EngineInterface, unattended: boolean) {
645  unattendedClear = unattended
646  clearParked = false
647  retryTimer?.cancel()
648  retryTimer = $.clock.after(0, () => { void tryClear($) })
649}
650
651// R2-15: a timer-driven attempt has nobody to throw to. A failure parks the handover as an offer,
652// with a notice, instead of leaving it pending with no retry.
653async function tryClear($: EngineInterface) {
654  try {
655    await tryClearInner($)
656  } catch (err) {
657    clearParked = true
658    lastWait = null
659    await notify($, V.clearRejected)
660    await log($, 'guard.wait', { reason: 'clear-error', error: String(err) }).catch(() => {})
661  }
662}
663
664async function tryClearInner($: EngineInterface) {
665  retryTimer = null
666  if (clearInFlight || !(await read($, pendingA))) return
667  await reloadSettings($)
668  await checkInterlock($)   // spec §7: at session start AND before every clear (TEMPORARY)
669  const now = await nowMs($)
670  // An unattended clear is only for an unattended session with auto mode on: re-checked here, not
671  // just when it began (R1-06, R2-08). Every exit leaves the handover offered, with a notice.
672  const autoOff = unattendedClear && !settings.auto
673  if (autoOff || (unattendedClear && mode(activity, now, settings) === 'attended')) {
674    await setCountdown($, null)
675    lastWait = null
676    clearParked = true
677    await notify($, autoOff ? V.clearSkippedAutoOff : V.clearSkippedAttended)
678    await log($, 'clear.skipped', { reason: autoOff ? 'auto-off' : 'attended' })
679    return
680  }
681  const gate = clearGate({
682    now, draft: (await $.prompt.read()).text, onPhone: onPhone(activity), lastBridgeAt: activity.lastBridgeAt,
683    rcAutoClear: settings.rcAutoClear, latched: (await readLatch($)) !== null,
684    countdownEndsAt: await read($, countdownA), classicActive: standDown, unattended: unattendedClear,
685  })
686  if (gate.go) {
687    lastWait = null
688    await setCountdown($, null)
689    await log($, 'clear', { unattended: unattendedClear })
690    clearInFlight = true
691    try {
692      await $.command.run({ command: 'clear' })
693    } catch {
694      clearParked = true
695      await notify($, V.clearRejected)
696      await log($, 'guard.wait', { reason: 'clear-rejected' })
697    } finally {
698      clearInFlight = false
699    }
700    return
701  }
702  if (gate.reason !== lastWait) {
703    lastWait = gate.reason
704    await notify($, V.waiting(gate.reason))
705    await log($, 'guard.wait', { reason: gate.reason, recheckMs: gate.recheckMs })
706  }
707  if (gate.reason === 'countdown-start') await setCountdown($, now + (gate.recheckMs ?? 0))
708  if (gate.recheckMs === null) { clearParked = true; return }
709  retryTimer = $.clock.after(gate.recheckMs, () => { void tryClear($) })
710}
711
712async function setLatch($: EngineInterface, l: Latch) {
713  if (!l) return
714  const existing = await readLatch($)
715  if (!existing) {
716    await $.store.set(LATCH_KEY, l)
717    await log($, 'limit.latched', { window: l.kind, resetsAtMs: l.resetsAtMs })
718    await notify($, V.limitLatched(formatHHMM(l.resetsAtMs)))
719  } else if (latchTimer) return
720  // R2-04: a latch another session set is lifted by this process too, or an idle one waits forever.
721  const target = existing ?? l
722  latchTimer?.cancel()
723  latchTimer = $.clock.after(Math.max(0, target.resetsAtMs - (await nowMs($))) + 1000, () => { latchTimer = null; void checkLatch($, []).catch(err => timerFailed($, 'latch-lift', err)) })
724}
725
726// The latch is account-wide: another session may lift it (delete the key) and drain only its
727// own work, so an absent latch drains this session's deferred handover too.
728async function checkLatch($: EngineInterface, limits: RateLimit[]) {
729  const l = await readLatch($)
730  if (l) {
731    if (!latchCleared(l, await nowMs($), limits)) return
732    await $.store.delete(LATCH_KEY)
733    await log($, 'limit.cleared', { window: l.kind })
734    await notify($, V.limitCleared)
735  }
736  const deferred = await read($, deferredA)
737  if (deferred) {
738    await reloadSettings($)
739    await update($, deferredA, () => null)
740    // R1-09: hours later the person may be anywhere. A threshold or request runs only if auto mode
741    // is on and they are not attended, and then as an unattended handover (attended re-check, RC
742    // gate). A last light should not get here (shouldFire refuses under a latch); drop it quietly.
743    if (deferred.reason === 'limit') await startHandover($, deferred.reason, deferred.resume, false)
744    else if (deferred.reason !== 'last_light' && settings.auto && mode(activity, await nowMs($), settings) !== 'attended') {
745      await startHandover($, deferred.reason, deferred.resume, true)
746    } else {
747      if (deferred.reason !== 'last_light') await notify($, V.deferredDropped)
748      await log($, 'guard.wait', { reason: 'deferred-dropped', deferred: deferred.reason })
749    }
750    return
751  }
752  // Only a clear the latch parked: one parked for a cancelled countdown or a refusal stays put.
753  // R2-01: hours later it follows the deferred handover's rule — only if auto mode is on, the person
754  // is still away and no turn has run since the write, and then as an unattended clear. Else offered.
755  if (!clearParked || lastWait !== 'latched') return
756  const parked = await read($, pendingA)
757  if (!parked) return
758  await reloadSettings($)
759  const now = await nowMs($)
760  const away = mode(activity, now, settings) !== 'attended'
761  if (settings.auto && away && fresh(parked, await read($, lastApiA), now)) scheduleClear($, true)
762  else await dropParkedClear($, parked, !settings.auto ? 'auto-off' : !away ? 'attended' : 'stale')
763}
764
765// Waits in hops of at most an hour; never submits while latched (spec §5); drops the limit
766// handover once the resume is sent so a later /clear does not re-inject it. One chain per process
767// (R1-12): a second early stop moves the existing job's time out, it never starts a second chain.
768// Nothing is sent over a person who has come back since the stop, or over a draft: a notice names
769// the handover instead. The path lives on the job, so a clear that consumed the pending handover
770// in between still names the file.
771async function scheduleResume($: EngineInterface, at: number) {
772  const stoppedAt = await nowMs($)
773  resumeChain?.cancel()
774  limitResume = { path: limitResume?.path ?? null, at: Math.max(limitResume?.at ?? 0, at), stoppedAt: limitResume?.stoppedAt ?? stoppedAt, gen: ++resumeGen }
775  hopResume($, 0, limitResume)
776}
777
778function hopResume($: EngineInterface, delayMs: number, job: NonNullable<typeof limitResume>) {
779  resumeChain = $.clock.after(delayMs, async () => {
780    if (job.gen !== resumeGen) return
781    const wait = nextHop(await nowMs($), job.at)
782    if (wait > 0) { hopResume($, wait, job); return }
783    await checkLatch($, [])   // R2-04: an expired latch nobody lifted is lifted here
784    if (await readLatch($)) { hopResume($, 60_000, job); return }
785    const pending = await read($, pendingA)
786    const path = job.path ?? pending?.path ?? null
787    const back = activity.lastHumanAt !== null && activity.lastHumanAt > job.stoppedAt
788    if (back || (await $.prompt.read()).text.trim()) {
789      await notify($, V.resumeSkipped(path))
790      await log($, 'guard.wait', { reason: 'resume-skipped', human: true })
791      limitResume = null
792      return
793    }
794    const outcome = await submitWithRetry($, { text: limitResumeText(path) }, 0)
795    if (outcome === 'cancelled') {
796      await notify($, V.resumeSkipped(path))
797      limitResume = null
798      return
799    }
800    if (outcome === 'failed') {
801      await notify($, V.resumeFailed(path, null))
802      await log($, 'guard.wait', { reason: 'submit-rejected' })
803      limitResume = null   // R3-05: the chain has ended; the next early stop starts its own job
804      resumeChain = null
805      return
806    }
807    limitResume = null
808    if (pending?.reason === 'limit') await savePending($, null)
809  })
810}
811
812async function cancelCountdown($: EngineInterface) {
813  if ((await read($, countdownA)) === null) return
814  await setCountdown($, null)
815  retryTimer?.cancel()
816  retryTimer = null
817  clearParked = true
818  lastWait = null
819  await notify($, V.countdownCancelled)
820  await log($, 'guard.wait', { reason: 'countdown-cancelled' })
821}
822
823async function showNudge($: EngineInterface, pct: number) {
824  if (settings.bar && !onPhone(activity)) {
825    if (await read($, barDismissedA)) return          // 0 hid it for this cycle: silent
826    await update($, barShownA, () => true)
827    await log($, 'bar', { action: 'shown', pct })
828    return
829  }
830  await notify($, V.nudge(pct))
831}
832
833async function barChoice($: EngineInterface, action: 'handover' | 'later' | 'dismiss') {
834  // R2-07: a pressed button is the person being here, same as the countdown's Cancel (R1-22).
835  await observe($, { kind: 'human-command', at: await nowMs($) })
836  await update($, barShownA, () => false)
837  if (action === 'dismiss') await update($, barDismissedA, () => true)
838  await log($, 'bar', { action })
839  if (action === 'handover') await startHandover($, 'request', true)
840}
841
842// Whether `sh` runs here (it does not on Windows); asked once per process.
843let shellRuns: boolean | undefined
844async function hasShell($: EngineInterface): Promise<boolean> {
845  if (shellRuns === undefined) shellRuns = await $.process.run(['sh', '-c', 'exit 0'], { timeoutMs: 5000 }).then(r => r.exitCode === 0, () => false)
846  return shellRuns
847}
848
849// Does the transcript hold a custom-title line? null = could not tell.
850async function sessionNamed($: EngineInterface, transcriptPath: string): Promise<boolean | null> {
851  try {
852    if (!(await hasShell($))) {
853      const text = await $.fs.read(transcriptPath).catch(() => undefined) // over the read cap or missing: cannot tell
854      return typeof text === 'string' ? text.includes('"type":"custom-title"') : null
855    }
856    const r = await $.process.run(['grep', '-c', '-F', '"type":"custom-title"', transcriptPath])
857    if (r.exitCode === 0) return Number.parseInt(r.stdout, 10) > 0
858    return r.exitCode === 1 ? false : null
859  } catch {
860    return null
861  }
862}
863
864// /clear carries an existing name into the new session, so only an unnamed one is renamed.
865// Never blocks the resume: a failed rename is a notice, not an error.
866async function renameSession($: EngineInterface, name: string | undefined, oldTranscript: string | undefined) {
867  if (!name?.trim()) return   // an older stored handover may carry no name
868  const named = oldTranscript === undefined ? null : await sessionNamed($, oldTranscript)
869  if (named === true) return void (await log($, 'rename', { kept: true }))
870  if (named === null) return void (await log($, 'guard.wait', { reason: 'rename-unknown' }))
871  await log($, 'rename', { name })
872  try {
873    await $.command.run({ command: 'rename', args: name })
874  } catch {
875    await notify($, V.renameFailed)
876    await log($, 'guard.wait', { reason: 'rename-rejected' })
877  }
878}
879
880function scheduleLastLight($: EngineInterface, lastApiAt: number, now: number) {
881  lastLightTimer?.cancel()
882  lastLightTimer = null
883  if (!settings.lastLight) return
884  if (now >= lastApiAt + TTL_1H) return   // no fire for a cache that is already cold
885  lastLightTimer = $.clock.after(Math.max(0, fireAt(lastApiAt) - now), () => { void maybeFireLastLight($).catch(err => timerFailed($, 'last-light', err)) })
886}
887
888// The one place the cache lifetime is asked (PROBES §11): scheduling assumed 1 hour, the latest
889// write in the transcript's tail says whether that held. Nothing found is no fire; no retry.
890async function readTtl($: EngineInterface): Promise<CacheTtl> {
891  let writes = null
892  try {
893    const path = (await read($, transcriptA)) ?? (root ? transcriptPathFor(root, await $.session.cwd(), await $.session.id()) : null)
894    if (!path) return ttlFromWrites(null)
895    if (await hasShell($)) {
896      const r = await $.process.run(['sh', '-c', TAIL_CMD, 'sh', path])
897      if (r.exitCode === 0) writes = parseWrites(r.stdout)
898    } else {
899      const text = await $.fs.read(path).catch(() => undefined)
900      if (typeof text === 'string') writes = parseWrites(cacheLinesFromText(text))
901    }
902  } catch { /* unknown */ }
903  return ttlFromWrites(writes)
904}
905
906async function cacheIsOneHour($: EngineInterface): Promise<boolean> {
907  const ttl = await readTtl($)
908  if (ttl !== '1h') await log($, 'last_light.skip', { reason: `ttl-${ttl}` })
909  return ttl === '1h'
910}
911
912async function setCacheTtl($: EngineInterface, to: CacheTtl, source: 'response' | 'switch') {
913  const from = await read($, cacheTtlA)
914  if (to === from) return
915  await update($, cacheTtlA, () => to)
916  if (to === '5m') await update($, ttlInfoDismissedA, () => false)   // shown again until the next message
917  if (!settings.lastLight) return
918  if (to === '5m') await log($, 'last_light.off', { ttl: to, source })
919  else if (from === '5m' && to === '1h') {
920    await log($, 'last_light.on', { ttl: to, source })
921    await notify($, V.lastLightBackOn)
922  }
923}
924
925// The session's first response that wrote to the cache: one detached tail read, never again.
926async function learnSessionTtl($: EngineInterface) {
927  await setCacheTtl($, await readTtl($), 'response')
928}
929
930async function maybeFireLastLight($: EngineInterface) {
931  lastLightTimer = null
932  await reloadSettings($)
933  const now = await nowMs($)
934  await observe($, { kind: 'agent-step', at: activity.lastAgentAt ?? 0 })  // picks up a draft (spec §2)
935  // Last light runs on the cache's clock, not the idle window (§2 vs §4): you are idle when nothing
936  // has come from you since the agent's last API activity (R1-07). `lastHumanAt` moves on a prompt,
937  // a command, an edit and the draft pickup above.
938  const lastApiAt = await read($, lastApiA)
939  const youIdle = lastApiAt !== null && (activity.lastHumanAt === null || activity.lastHumanAt <= lastApiAt)
940  const agentIdle = activity.lastAgentAt === null || now - activity.lastAgentAt >= WORKING_MS
941  // The file may have been edited while everyone was idle: decide on what it says now.
942  await loadOverrides($)
943  const verdict = shouldFire({
944    enabled: settings.lastLight, youIdle, agentIdle,
945    contextPct: await read($, contextA), threshold: (await thresholds($)).values.lastLightAt,
946    pending: (await read($, pendingA)) !== null, latched: (await readLatch($)) !== null,
947    armed: lastLightArmed,
948  })
949  if (!verdict.fire) return
950  // R2-14: under stand-down nothing is read, fired or spent (spec §7, SMOKES #7).
951  if (standDown) { await log($, 'last_light.skip', { reason: 'standdown' }); return }
952  if (!(await cacheIsOneHour($))) return
953  const contextPct = await read($, contextA)
954  const outcome = await startHandover($, 'last_light', false)
955  if (outcome !== 'started') { await log($, 'last_light.skip', { reason: outcome }); return }
956  lastLightArmed = false
957  await log($, 'last_light.fired', { contextPct })
958}
959
960// One ask at a time: a message typed meanwhile joins the first instead of opening a second ask whose
961// answer would undo the first. Whichever answer acts first consumes the held texts, atomically, so a
962// dialog that outlives a reload can never send them twice (R2-11).
963async function askReturn($: EngineInterface) {
964  const choice = String(await $.ui.ask(V.lastLightAsk, [V.lastLightResume, V.lastLightCarryOn]).catch(() => V.lastLightCarryOn))
965  const taken: { v: string[] | null } = { v: null }
966  await update($, returnHeldA, h => { taken.v = h; returnHeldMirror = null; return null })
967  if (!taken.v?.length) { await log($, 'last_light.choice', { stale: true }); return }   // already answered, or a clear took it
968  // Free text under "Other" is never a clear: carry on, with what was typed kept (intent: when in doubt, don't).
969  const typed = choice !== V.lastLightResume && choice !== V.lastLightCarryOn && choice.trim() ? [choice] : []
970  const held = [...taken.v, ...typed].join('\n\n')
971  const resume = choice === V.lastLightResume
972  await log($, 'last_light.choice', { choice: resume ? 'resume' : 'carry_on', ...(typed.length ? { typed: true } : {}) })
973  const pending = await read($, pendingA)
974  if (resume && pending) {
975    await savePending($, { ...pending, resume: false, followUp: held })
976    scheduleClear($, false)
977    return
978  }
979  await savePending($, null)
980  submitSoon($, { text: held, asUser: true }, 0, undefined, () => { void resumeFailed($, pending?.path ?? null, held) })
981}
982
983export const register: Register = on => {
984  on('session.start', async ($, e, next) => {
985    const r = await next(e)
986    await bindSession($)
987    await $.command.register({ name: COMMANDS.handover, description: V.cmdHandover })
988    await $.command.register({ name: COMMANDS.handoverShort, description: V.cmdHandoverShort })
989    await $.command.register({ name: COMMANDS.setup, description: V.cmdSetup })
990    await $.command.register({ name: COMMANDS.overrides, description: V.cmdOverrides })
991    await $.tool.register({ name: TOOL, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA as unknown as Record<string, unknown> })
992    await checkInterlock($)
993    await checkLatch($, [])   // a latch another process left behind and never lifted
994    // One still live gets this process's lift timer: its setter may have exited (setLatch arms it for an existing latch).
995    await setLatch($, await readLatch($))
996    await prunePending($)
997    const stored = (await $.store.get(pendingKey(session))) as Pending | null | undefined
998    const live = await read($, pendingA)
999    if (stored && !live) {
1000      await update($, pendingA, () => stored)
1001      await notify($, V.pendingOffer(stored.path))
1002    } else if (live) {
1003      // A hot reload: $.state kept the pending handover but its clear's timers are gone. The
1004      // reload also forgot the phone, so only a clear the person asked for is picked back up;
1005      // an unattended one is offered, never run past an RC answer or a countdown it can't see.
1006      await setCountdown($, null)
1007      if (live.reason === 'request' && !live.unattended && reusable(live, await read($, lastApiA), await nowMs($))) scheduleClear($, false)
1008      else await notify($, V.pendingOffer(live.path))
1009    }
1010    // A reload counts as the person being here (bindSession), so this period's last light is skipped
1011    // when the timer fires (not-idle, R1-07 deferred half). The timer is still armed here so that
1012    // fix can make it live.
1013    const lastApi = await read($, lastApiA)
1014    if (lastApi !== null) {
1015      lastLightArmed = true
1016      scheduleLastLight($, lastApi, await nowMs($))
1017    }
1018    scheduleGit($)
1019    // R2-11: a reload while the return question was open: its dialog's closure is gone, ask again.
1020    returnHeldMirror = await read($, returnHeldA)   // a real reload reset the module copy; $.state kept the text
1021    if ((await read($, returnHeldA))?.length && (await read($, pendingA))?.reason === 'last_light') $.clock.after(0, () => { void askReturn($) })
1022    return r
1023  })
1024
1025  on('classic.SessionStart', async ($, e, next) => {
1026    // A different conversation begins (a /clear, a /resume, a start): a resume still retrying belongs to the old one.
1027    if (e.source !== 'compact') submitGen++
1028    const r = await next(e)
1029    const gitDir = await $.process
1030      .run(GIT_DIR_ARGV, { cwd: await $.session.cwd() })
1031      .then(x => ({ exitCode: x.exitCode, stdout: x.stdout }))
1032      .catch(() => ({ exitCode: 1, stdout: '' }))
1033    const watch = watchPaths(gitDir)
1034    const out = watch.length ? { ...r, watchPaths: [...(r.watchPaths ?? []), ...watch] } : r
1035    if (e.transcript_path) await update($, transcriptA, () => e.transcript_path ?? null)
1036    if (e.source === 'resume' || e.source === 'fork') {
1037      // R1-08: session.start fires once per process, so an in-process /resume or fork is a new
1038      // session that only this event announces. Rebind it; whatever it parked is offered, never
1039      // the old session's.
1040      session = await $.session.id()
1041      // A resume is the person acting now: the old conversation's idle time must not read as away.
1042      activity = { ...EMPTY_ACTIVITY, ...(await read($, phoneA)), lastHumanAt: await nowMs($) }
1043      resetCaches()
1044      // R2-16: process-wide jobs belong to the conversation that is gone.
1045      if (resumeChain || limitResume) await log($, 'guard.wait', { reason: 'resume-dropped', cause: 'session-changed' })
1046      resumeChain?.cancel()
1047      resumeChain = null
1048      limitResume = null
1049      // R3-02: never submit into a different conversation; say what was not sent.
1050      const heldAway = returnHeldMirror
1051      returnHeldMirror = null
1052      if (heldAway?.length) {
1053        await notify($, V.heldNotSent(heldAway.join('\n\n')))
1054        await log($, 'last_light.choice', { choice: 'dropped', viaResume: true })
1055      }
1056      await checkInterlock($)
1057      scheduleGit($)
1058      await resetSessionState($)
1059      await update($, lastApiA, () => null)
1060      lastApiMirror = null
1061      const stored = ((await $.store.get(pendingKey(session))) as Pending | null | undefined) ?? null
1062      await update($, pendingA, () => stored)
1063      if (stored) await notify($, V.pendingOffer(stored.path))
1064      return out
1065    }
1066    if (e.source === 'compact') {
1067      // R1-13: the context just shrank; the nudge ladder and its bar start over, nothing else does.
1068      await update($, lastNudgedA, () => null)
1069      await update($, baselineA, () => null)
1070      await update($, barShownA, () => false)
1071      await update($, barDismissedA, () => false)
1072      return out
1073    }
1074    if (e.source !== 'clear') return out
1075    // PROBES §9: $.state is already wiped here, so the handover comes from $.store, keyed by
1076    // `session` — still the pre-clear id until it is rebound below.
1077    const pending = ((await $.store.get(pendingKey(session))) as Pending | null | undefined) ?? null
1078    if (pending) await $.store.delete(pendingKey(session))
1079    const heldOnClear = returnHeldMirror?.length ? returnHeldMirror.join('\n\n') : null   // R3-02
1080    returnHeldMirror = null
1081    const apiBefore = lastApiMirror
1082    lastApiMirror = null   // the new session has run no turn
1083    const before = session
1084    session = await $.session.id()
1085    // A slow start can leave the engine still answering the old id: this clear's events then land in the
1086    // old session's file. Say so rather than look like a log that went missing.
1087    if (session === before) {
1088      try { await $.ui.log(`context-vigil-mod: session id not yet rebound after the clear (still ${session}); its events go to that session's log`) } catch { /* nowhere left to say it */ }
1089    }
1090    resetCaches()
1091    scheduleGit($)
1092    await resetSessionState($)
1093    if (activity.lastHumanOrigin !== null) await savePhoneFacts($)   // the wipe took them; a later reload needs them
1094    if (heldOnClear) await log($, 'last_light.choice', { choice: 'resume', viaClear: true })
1095    if (!pending) {
1096      if (heldOnClear) resumeSoon($, { text: heldOnClear, asUser: true }, null, heldOnClear)
1097      return out
1098    }
1099    const follow = [pending.followUp, heldOnClear].filter(Boolean).join('\n\n') || null
1100    // /rename starts from its own timer, before the resume submit, and never blocks it.
1101    const tp = e.transcript_path
1102    const oldTranscript = tp === undefined ? undefined : `${tp.slice(0, tp.lastIndexOf('/') + 1)}${pending.session}.jsonl`
1103    $.clock.after(0, () => { void renameSession($, pending.name, oldTranscript) })
1104    // The person's own held text is always sent. The mod's resume prompt needs a fresh handover
1105    // (no turn since it was written) and no latch (R1-10); otherwise a notice says why not.
1106    let stale = false
1107    let noResume = false
1108    if (follow) resumeSoon($, { text: follow, asUser: true }, pending.path, follow)
1109    else if (pending.resume) {
1110      if (!fresh(pending, apiBefore, await nowMs($))) {
1111        stale = true
1112        await notify($, V.resumeStale(pending.path))
1113      } else if (await readLatch($)) {
1114        await notify($, V.resumeLatched(pending.path))
1115        await log($, 'guard.wait', { reason: 'latched', deferred: 'resume' })
1116      } else resumeSoon($, { text: resumeText(pending.path) }, pending.path, null)
1117    } else {
1118      // R2-17: a handover that carries no automatic resume (a limit or last-light one) is still
1119      // injected on a manual /clear; never silently.
1120      await notify($, V.injectedNoResume(pending.path))
1121      noResume = true
1122    }
1123    await log($, 'resume', { path: pending.path, reason: pending.reason, followUp: follow !== null, ...(stale ? { stale: true } : {}), ...(noResume ? { noResume: true } : {}) })
1124    return { ...out, additionalContext: [...(out.additionalContext ?? []), injectText(pending.markdown)] }
1125  })
1126
1127  on('classic.StopFailure', async ($, e, next) => {
1128    const error = String((e as unknown as { error?: unknown }).error ?? '')
1129    const usage = await $.session.usage().catch(() => null)
1130    await setLatch($, latchFromStopFailure(error, usage?.rateLimits ?? [], await nowMs($)))
1131    return next(e)
1132  })
1133
1134  on('classic.FileChanged', async ($, e, next) => {
1135    scheduleGit($)
1136    return next(e)
1137  })
1138
1139  on('prompt.submit', async ($, e, next) => {
1140    if (classifyOrigin(e.origin.kind) === 'human') submitGen++   // the person's own prompt wins over a resume still retrying
1141    const now = await nowMs($)
1142    if (rearm(lastLightArmed, e.origin.kind)) lastLightArmed = true
1143    const pending = await read($, pendingA)
1144    const lastApi = await read($, lastApiA)
1145    // R2-05: no turn seen in this process (a restart, a /resume) = the cache clock is the last-light
1146    // turn's own, which ran at about the handover's creation; never 'unknown, so drop it'.
1147    if (holdOnReturn({ pendingIsLastLight: pending?.reason === 'last_light', origin: e.origin.kind, now, cacheExpiresAt: lastApi !== null ? lastApi + TTL_1H : pending?.reason === 'last_light' ? pending.createdAt + TTL_1H : null })) {
1148      await observe($, { kind: 'prompt', origin: e.origin.kind, at: now })
1149      let open = false
1150      let after: string[] = []
1151      await update($, returnHeldA, h => { open = h !== null; after = [...(h ?? []), e.text]; return after })
1152      returnHeldMirror = after
1153      if (!open) $.clock.after(0, () => { void askReturn($) })
1154      return { drop: V.heldForLastLight }
1155    }
1156    await observe($, { kind: 'prompt', origin: e.origin.kind, at: await nowMs($) })
1157    // R1-05: back while the cache is still warm, the last-light handover has no job left: drop it
1158    // (the file stays), or it blocks every later last light and is offered stale as "cheap".
1159    if (pending?.reason === 'last_light' && classifyOrigin(e.origin.kind) === 'human') {
1160      await savePending($, null)
1161      await log($, 'last_light.dropped', { path: pending.path })
1162    }
1163    // R1-20: the "last light is off" line goes away with the person's next message (no hotkey: spec §3).
1164    if (classifyOrigin(e.origin.kind) === 'human' && (await read($, cacheTtlA)) === '5m') await update($, ttlInfoDismissedA, () => true)
1165    if (e.origin.kind !== 'plugin') await cancelCountdown($)
1166    if (classifyOrigin(e.origin.kind) === 'human') {
1167      // A short plain request to hand over runs exactly what /vho runs, not whatever the model improvises.
1168      const request = !standDown && isHandoverRequest(e.text)
1169      await update($, handoverMentionedA, () => !request && mentionsHandover(e.text))
1170      if (request) {
1171        $.clock.after(0, () => { void startHandover($, 'request', true).catch(err => timerFailed($, 'handover-request', err)) })
1172        return { drop: V.handoverRequested }
1173      }
1174    }
1175    return next(e)
1176  })
1177
1178  on('command.run', async ($, e, next) => {
1179    if (classifyOrigin(e.origin.kind) === 'human') await observe($, { kind: 'human-command', at: await nowMs($) })
1180    return next(e)
1181  })
1182
1183  on('prompt.edit', async ($, e, next) => {
1184    await observe($, { kind: 'edit', at: await nowMs($) })
1185    return next(e)
1186  })
1187
1188  on('turn.start', async ($, e, next) => {
1189    scheduleGit($)
1190    // The instruction's own turn is the only one whose end counts as an attempt (R1-02).
1191    await update($, awaitingA, a => (a && a.turnId === undefined && e.text === instructionText(a.reason) ? { ...a, turnId: e.turnId } : a))
1192    return next(e)
1193  })
1194
1195  on('tool.call', async ($, e, next) => {
1196    const r = await next(e)
1197    const input = e as unknown as { tool: string; file_path?: unknown }
1198    await observe($, { kind: 'agent-step', at: await nowMs($) })
1199    if ((input.tool === 'Edit' || input.tool === 'Write') && typeof input.file_path === 'string') edited.add(input.file_path)
1200    if (touchesGit(input.tool)) scheduleGit($)
plugin/core/overrides.ts 229 lines
1import type { Override, OverrideValues } from '../types'
2
3// Threshold overrides keyed by model, window or both, in a hand-editable overrides.json.
4// Each value comes from the most specific matching override that sets it, field by field; the
5// account settings sit beneath them all.
6//
7//   { model: 'opus5.5', window: 1M }  >  { model: 'opus', window: 1M }  >  { window: 1M }
8//   >  { model: 'opus5.5' }  >  { model: 'opus' }  >  settings
9
10export const VALUE_KEYS = ['nudgeAt', 'step', 'lastLightAt'] as const
11type ValueKey = typeof VALUE_KEYS[number]
12
13export const DEFAULT_OVERRIDES: Override[] = [{ window: 200_000, nudgeAt: 70 }]
14
15// 'claude-opus-5-5[1m]', 'Opus 5.5 (1M context)' and 'opus5.5' all lead with opus·5·5.
16export function modelTokens(model: string): string[] {
17  const t = model.toLowerCase().match(/[a-z]+|\d+/g) ?? []
18  return t[0] === 'claude' ? t.slice(1) : t
19}
20
21// A pattern is a family and at most a major.minor version: 'opus', 'opus5', 'opus5.5'.
22export function validPattern(pattern: string): boolean {
23  const t = modelTokens(pattern)
24  return t.length >= 1 && t.length <= 3 && /^[a-z]+$/.test(t[0]!) && t.slice(1).every(x => /^\d+$/.test(x))
25}
26
27// The pattern naming a model's version: 'claude-haiku-4-5-20251001' → 'haiku4.5'.
28export function patternFor(model: string): string | null {
29  const t = modelTokens(model)
30  if (!t[0] || !/^[a-z]+$/.test(t[0])) return null
31  const nums: string[] = []
32  for (const x of t.slice(1)) { if (!/^\d+$/.test(x) || x.length > 2 || nums.length === 2) break; nums.push(x) }
33  return t[0] + nums.join('.')
34}
35
36export function modelMatches(pattern: string, model: string): boolean {
37  const p = modelTokens(pattern)
38  const m = modelTokens(model)
39  return p.length > 0 && p.length <= m.length && p.every((x, i) => x === m[i])
40}
41
42// Both keys beat one; of one, window beats model; between models, the longer pattern wins.
43function specificity(r: Override): number {
44  const tier = r.model !== undefined && r.window !== undefined ? 3 : r.window !== undefined ? 2 : 1
45  return tier * 1_000 + (r.model === undefined ? 0 : modelTokens(r.model).length)
46}
47
48const matches = (r: Override, model: string | null, window: number | null): boolean =>
49  (r.model === undefined || (model !== null && modelMatches(r.model, model)))
50  && (r.window === undefined || r.window === window)
51
52export type Ambiguity = { model: Override; window: Override; fields: ValueKey[] }
53
54export type Resolved = {
55  values: OverrideValues
56  from: Partial<Record<ValueKey, Override>>
57  // A window-only and a model-only override both set a field and nothing with both keys settles it.
58  ambiguous: Ambiguity | null
59}
60
61export function resolve(base: OverrideValues, overrides: Override[], model: string | null, window: number | null): Resolved {
62  const hits = ordered(overrides.filter(r => matches(r, model, window)))
63  const values: OverrideValues = { ...base }
64  const from: Partial<Record<ValueKey, Override>> = {}
65  for (const k of VALUE_KEYS) {
66    const r = hits.find(h => h[k] !== undefined)
67    if (r) { values[k] = r[k]!; from[k] = r }
68  }
69  const byWindow = hits.find(h => h.model === undefined)
70  const modelOnly = hits.filter(h => h.window === undefined)
71  const fields = byWindow ? VALUE_KEYS.filter(k => from[k] === byWindow && modelOnly.some(h => h[k] !== undefined)) : []
72  const byModel = modelOnly.find(h => fields.some(k => h[k] !== undefined))
73  return { values, from, ambiguous: byWindow && byModel ? { model: byModel, window: byWindow, fields } : null }
74}
75
76export function ordered(overrides: Override[]): Override[] {
77  return [...overrides].sort((a, b) => specificity(b) - specificity(a))
78}
79
80const keyOf = (r: Pick<Override, 'model' | 'window'>): string =>
81  `${r.model === undefined ? '*' : modelTokens(r.model).join('.')}@${r.window ?? '*'}`
82
83export const sameKey = (a: Pick<Override, 'model' | 'window'>, b: Pick<Override, 'model' | 'window'>): boolean => keyOf(a) === keyOf(b)
84
85// Adding an override replaces the one with the same key.
86export function setOverride(overrides: Override[], override: Override): Override[] {
87  return [...overrides.filter(r => !sameKey(r, override)), override]
88}
89
90export function removeOverride(overrides: Override[], key: Pick<Override, 'model' | 'window'>): { overrides: Override[]; removed: boolean } {
91  const next = overrides.filter(r => !sameKey(r, key))
92  return { overrides: next, removed: next.length !== overrides.length }
93}
94
95// '1m', '1M', '200k', '1000000' → tokens.
96export function parseWindow(s: string): number | null {
97  const m = /^(\d+(?:\.\d+)?)([km]?)$/i.exec(s.trim())
98  if (!m) return null
99  const unit = m[2]?.toLowerCase()
100  const scaled = Number(m[1]) * (unit === 'k' ? 1_000 : unit === 'm' ? 1_000_000 : 1)
101  // Only float noise is rounded (1.1m); a count that is not whole (1.4, 1.0005k) is refused, so
102  // `rm window=1.4` can never take out window=1.
103  const n = Math.round(scaled)
104  return Math.abs(scaled - n) < 1e-6 && n > 0 ? n : null
105}
106
107export function formatWindow(n: number): string {
108  return n % 1_000_000 === 0 ? `${n / 1_000_000}M` : n % 1_000 === 0 ? `${n / 1_000}k` : `${n}`
109}
110
111export function formatKey(r: Pick<Override, 'model' | 'window'>): string {
112  return [r.model !== undefined ? `model=${r.model}` : '', r.window !== undefined ? `window=${formatWindow(r.window)}` : ''].filter(Boolean).join(' ')
113}
114
115const LABEL: Record<ValueKey, string> = { nudgeAt: 'nudge', step: 'step', lastLightAt: 'last light' }
116
117export function formatOverride(r: Override): string {
118  return `${formatKey(r)} → ${VALUE_KEYS.filter(k => r[k] !== undefined).map(k => `${LABEL[k]} ${r[k]}%`).join(', ')}`
119}
120
121// overrides.json: { "overrides": [ ... ] }. A fault drops only the override it is in; each is named for the person.
122// A file that cannot be read as a list at all (`fileFault`) names no override: the caller keeps what it had.
123export type Checked = { overrides: Override[]; faults: string[]; fileFault?: true }
124
125const KEYS = new Set<string>(['model', 'window', ...VALUE_KEYS])
126const isPct = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 100
127
128export function checkOverrides(text: string): Checked {
129  let raw: unknown
130  try { raw = JSON.parse(text) } catch (err) { return { overrides: [], faults: [`not valid JSON (${String((err as Error).message).slice(0, 80)})`], fileFault: true } }
131  const list = (raw as { overrides?: unknown } | null)?.overrides
132  if (!raw || typeof raw !== 'object' || !Array.isArray(list)) return { overrides: [], faults: ['expected { "overrides": [ … ] }'], fileFault: true }
133  const overrides: Override[] = []
134  const faults: string[] = []
135  list.forEach((v, i) => {
136    const at = `override ${i + 1}`
137    if (!v || typeof v !== 'object' || Array.isArray(v)) { faults.push(`${at}: not an object`); return }
138    const o = v as Record<string, unknown>
139    const unknown = Object.keys(o).filter(k => !KEYS.has(k))
140    if (unknown.length) { faults.push(`${at}: unknown key ${unknown.map(k => `"${k}"`).join(', ')} (allowed: ${[...KEYS].join(', ')})`); return }
141    if (o.model !== undefined && (typeof o.model !== 'string' || !validPattern(o.model))) { faults.push(`${at}: model must be a family and optional version, like "opus" or "opus5.5"`); return }
142    if (o.window !== undefined && !(typeof o.window === 'number' && Number.isInteger(o.window) && o.window > 0)) { faults.push(`${at}: window must be a whole number of tokens, like 1000000`); return }
143    if (o.model === undefined && o.window === undefined) { faults.push(`${at}: needs a model, a window or both`); return }
144    for (const k of ['nudgeAt', 'lastLightAt'] as const) if (o[k] !== undefined && !isPct(o[k])) { faults.push(`${at}: ${k} must be a whole % from 1 to 100`); return }
145    if (o.step !== undefined && !(typeof o.step === 'number' && Number.isInteger(o.step) && o.step >= 1 && o.step <= 50)) { faults.push(`${at}: step must be a whole % from 1 to 50`); return }
146    if (!VALUE_KEYS.some(k => o[k] !== undefined)) { faults.push(`${at}: sets nothing (give it ${VALUE_KEYS.join(', ')} or any of them)`); return }
147    const r = o as Override
148    const dup = overrides.findIndex(x => sameKey(x, r))
149    if (dup !== -1) { faults.push(`${at}: same key as an earlier override (${formatKey(r)}); the earlier one stands`); return }
150    overrides.push(r)
151  })
152  return { overrides, faults }
153}
154
155export function overridesJson(overrides: Override[]): string {
156  return `${JSON.stringify({ overrides }, null, 2)}\n`
157}
158
159// 0.1.3 kept per-model overrides in the settings store (`modelThresholds`, substring patterns such
160// as `opus` or `[1m]`). They move into overrides.json once: `[1m]` becomes window=1M, the rest a
161// model pattern. One that does not fit this format is named in `dropped`, with why. Entries go in
162// 0.1.3's own order (longest pattern first, then by name), so when two collapse to one key
163// (`claude-opus`, `opus`) the one that used to win is the one kept.
164export function fromModelThresholds(raw: unknown): { overrides: Override[]; dropped: string[] } {
165  const table = (raw as { modelThresholds?: unknown } | null)?.modelThresholds
166  const out: Override[] = []
167  const dropped: string[] = []
168  if (!table || typeof table !== 'object' || Array.isArray(table)) return { overrides: out, dropped }
169  const entries = Object.entries(table as Record<string, unknown>)
170    .sort(([a], [b]) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0))
171  for (const [key, v] of entries) {
172    const o = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>
173    const lower = key.trim().toLowerCase()
174    const window = lower.includes('[1m]') ? 1_000_000 : undefined
175    const rest = lower.replace('[1m]', '').replace(/^[-\s]+|[-\s]+$/g, '')
176    const tokens = modelTokens(rest)
177    const model = rest ? (validPattern(rest) ? tokens[0]! + tokens.slice(1).join('.') : null) : undefined
178    const values = { ...(isPct(o.nudgeAt) ? { nudgeAt: o.nudgeAt } : {}), ...(isPct(o.lastLightAt) ? { lastLightAt: o.lastLightAt } : {}) }
179    if (model === null || (model === undefined && window === undefined)) { dropped.push(`${key} (no equivalent)`); continue }
180    if (!Object.keys(values).length) { dropped.push(`${key} (sets nothing)`); continue }
181    const r: Override = { ...(model !== undefined ? { model } : {}), ...(window !== undefined ? { window } : {}), ...values }
182    const winner = out.find(x => sameKey(x, r))
183    if (winner) { dropped.push(`${key} (${formatKey(winner)} from a longer pattern won)`); continue }
184    out.push(r)
185  }
186  return { overrides: out, dropped }
187}
188
189// /vigil-overrides · /vigil-overrides add · /vigil-overrides rm [model=<m>] [window=<w>]
190export type OverridesCommand =
191  | { op: 'list' }
192  | { op: 'add' }
193  | { op: 'rm'; key: Pick<Override, 'model' | 'window'> }
194  | { op: 'error' }
195
196export function parseOverridesArgs(args: string): OverridesCommand {
197  const words = args.trim().split(/\s+/).filter(Boolean)
198  if (words.length === 0 || (words.length === 1 && (words[0] === 'list' || words[0] === 'check'))) return { op: 'list' }
199  if (words.length === 1 && words[0] === 'add') return { op: 'add' }
200  if (words[0] !== 'rm') return { op: 'error' }
201  const key = parseKey(words.slice(1).join(' '))
202  return key ? { op: 'rm', key } : { op: 'error' }
203}
204
205// 'model=opus5.5 window=1m' (either or both) → an override key; null when it names neither or is malformed.
206export function parseKey(text: string): Pick<Override, 'model' | 'window'> | null {
207  let model: string | undefined
208  let window: number | undefined
209  for (const w of text.trim().split(/\s+/).filter(Boolean)) {
210    const eq = w.indexOf('=')
211    const k = eq > 0 ? w.slice(0, eq).toLowerCase() : ''
212    const v = w.slice(eq + 1)
213    if (k === 'model' && model === undefined && validPattern(v)) { model = v; continue }
214    const n = k === 'window' && window === undefined ? parseWindow(v) : null
215    if (n !== null) { window = n; continue }
216    return null
217  }
218  if (model === undefined && window === undefined) return null
219  return { ...(model !== undefined ? { model } : {}), ...(window !== undefined ? { window } : {}) }
220}
221
222// The /vigil-overrides add dialog: questions, like setup's, are not notices and carry no emoji.
223export const ASK = {
224  key: 'Which sessions should this override cover? (Other: model=… window=…, either or both)',
225  nudge: (key: string) => `For ${key}: at what % of context should I suggest a handover?`,
226  step: 'Then nudge again every how many %?',
227  lastLight: 'Last light: only write its handover from what % of context?',
228}
229
plugin/core/name.ts 31 lines
1import { configRootOf, joinPath } from './home'
2import type { HomeEnv } from './home'
3
4export const NAME = 'context-vigil-mod'
5export const TOOL = 'vigil_handover'
6export const TOOL_FULL = `mcp__${NAME}__${TOOL}`
7export const COMMANDS = { handover: 'vigil-handover', handoverShort: 'vho', setup: 'vigil-setup', overrides: 'vigil-overrides' } as const
8
9// null when no home can be told (CLAUDE_CONFIG_DIR, HOME, USERPROFILE, HOMEDRIVE+HOMEPATH): a relative `.claude`
10// would land under the session's cwd, so callers write nothing.
11export function configRoot(env: HomeEnv): string | null {
12  return configRootOf(env)
13}
14
15export function handoverPath(root: string, session: string, n: number): string {
16  return joinPath(root, NAME, 'handovers', `${session}-${n}.md`)
17}
18
19export function overridesPath(root: string): string {
20  return joinPath(root, NAME, 'overrides.json')
21}
22
23export function eventsPath(root: string, day: string, session: string): string {
24  return joinPath(root, NAME, 'events', day, `${session}.jsonl`)
25}
26
27// TEMPORARY (interlock): where classic context-vigil keeps a session record.
28export function classicSessionPath(root: string, session: string): string {
29  return joinPath(root, 'context-vigil', 'sessions', `${session}.json`)
30}
31
plugin/core/settings.ts 46 lines
1import type { Settings, Window } from '../types'
2
3export const STORE_KEY = 'settings'
4
5// Per session: a second session on the account must never see or wipe this one's handover.
6export function pendingKey(session: string): string {
7  return `pending:${session}`
8}
9
10// A parked handover nobody came back for is not kept forever: $.store refuses writes past 4 MiB (R1-17).
11export const PENDING_KEEP_MS = 14 * 86_400_000
12export const PENDING_PREFIX = 'pending:'
13
14export const DEFAULTS: Settings = {
15  nudgeAt: 35, step: 5, bar: true, auto: false, idleMin: 30,
16  lastLight: false, lastLightAt: 25, limits: true, limitPct: 95,
17  limitWindows: ['seven_day', 'spend_limit'], rcAutoClear: 'unanswered',
18}
19
20const isPct = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 1 && v <= 100
21const inRange = (v: unknown, lo: number, hi: number): v is number =>
22  typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi
23
24export function loadSettings(raw: unknown): Settings {
25  const s: Settings = { ...DEFAULTS, limitWindows: [...DEFAULTS.limitWindows] }
26  if (!raw || typeof raw !== 'object') return s
27  const r = raw as Record<string, unknown>
28  if (r.modelThresholds !== undefined) s.modelThresholds = r.modelThresholds
29  if (isPct(r.nudgeAt)) s.nudgeAt = r.nudgeAt
30  if (inRange(r.step, 1, 50)) s.step = r.step
31  if (typeof r.bar === 'boolean') s.bar = r.bar
32  if (typeof r.auto === 'boolean') s.auto = r.auto
33  if (inRange(r.idleMin, 1, 1440)) s.idleMin = r.idleMin
34  if (typeof r.lastLight === 'boolean') s.lastLight = r.lastLight
35  if (isPct(r.lastLightAt)) s.lastLightAt = r.lastLightAt
36  if (typeof r.limits === 'boolean') s.limits = r.limits
37  if (isPct(r.limitPct)) s.limitPct = r.limitPct
38  if (Array.isArray(r.limitWindows)) {
39    const ok = r.limitWindows.filter((w): w is Window => w === 'seven_day' || w === 'spend_limit')
40    // An empty list would watch nothing while limits reads On: keep the defaults instead.
41    if (ok.length) s.limitWindows = [...new Set(ok)]
42  }
43  if (r.rcAutoClear === 'yes' || r.rcAutoClear === 'no' || r.rcAutoClear === 'unanswered') s.rcAutoClear = r.rcAutoClear
44  return s
45}
46
plugin/core/arming.ts 67 lines
1import type { Activity, Mode, Settings, Who } from '../types'
2
3const HUMAN = new Set(['composer', 'bridge', 'slack-ping'])
4// Presence evidence that is not attestably the person's own message: a same-user channel the engine
5// cannot attest (R1-24), a message relayed by an MCP channel server, the engine's follow-up to a UI
6// action (R2-06). They disarm auto mode and nothing else.
7const DISARM_ONLY = new Set(['unclassified', 'channel', 'auto-continuation'])
8
9export function classifyOrigin(kind: string): Who {
10  if (HUMAN.has(kind)) return 'human'
11  if (kind === 'sdk') return 'headless'
12  return 'agent'
13}
14
15export const EMPTY_ACTIVITY: Activity = {
16  lastHumanAt: null, lastHumanOrigin: null, lastBridgeAt: null, lastAgentAt: null, headless: false,
17}
18
19export type Signal =
20  | { kind: 'prompt'; origin: string; at: number }
21  | { kind: 'human-command'; at: number }
22  | { kind: 'edit'; at: number }
23  | { kind: 'agent-step'; at: number }
24
25export function record(a: Activity, s: Signal): Activity {
26  switch (s.kind) {
27    case 'prompt': {
28      // An origin the engine cannot attest might be a person: it disarms auto mode and restarts the
29      // idle clock, and counts for nothing else (not a phone fact, not a return, not agent work). R1-24.
30      if (DISARM_ONLY.has(s.origin)) return { ...a, lastHumanAt: s.at }
31      const who = classifyOrigin(s.origin)
32      if (who === 'human') {
33        return { ...a, lastHumanAt: s.at, lastHumanOrigin: s.origin, lastBridgeAt: s.origin === 'bridge' ? s.at : a.lastBridgeAt }
34      }
35      return { ...a, lastAgentAt: s.at, headless: a.headless || who === 'headless' }
36    }
37    case 'human-command':
38    case 'edit':
39      return { ...a, lastHumanAt: s.at }
40    case 'agent-step':
41      return { ...a, lastAgentAt: s.at }
42  }
43}
44
45export const WORKING_MS = 120_000
46
47export function mode(a: Activity, now: number, s: Pick<Settings, 'idleMin'>): Mode {
48  const engaged = !a.headless && a.lastHumanAt !== null && now - a.lastHumanAt < s.idleMin * 60_000
49  if (engaged) return 'attended'
50  const working = a.lastAgentAt !== null && now - a.lastAgentAt < WORKING_MS
51  return working ? 'auto' : 'idle'
52}
53
54export function armed(a: Activity, now: number, s: Pick<Settings, 'idleMin' | 'auto'>): boolean {
55  return s.auto && mode(a, now, s) === 'auto'
56}
57
58export function transition(prev: Mode, next: Mode): 'arm' | 'disarm' | null {
59  if (next === 'auto' && prev !== 'auto') return 'arm'
60  if (prev === 'auto' && next !== 'auto') return 'disarm'
61  return null
62}
63
64export function onPhone(a: Activity): boolean {
65  return a.lastHumanOrigin === 'bridge'
66}
67
plugin/core/eventlog.ts 15 lines
1import type { EventKind, EventRecord } from '../types'
2
3export function dayKey(ms: number): string {
4  return new Date(ms).toISOString().slice(0, 10)
5}
6
7export function makeRecord(ms: number, session: string, kind: EventKind, fields: Record<string, unknown>): EventRecord {
8  return { ...fields, ts: new Date(ms).toISOString(), session, kind }
9}
10
11export function appendLine(existing: string, rec: EventRecord): string {
12  const base = existing === '' || existing.endsWith('\n') ? existing : `${existing}\n`
13  return `${base}${JSON.stringify(rec)}\n`
14}
15
plugin/core/git.ts 36 lines
1import type { Git } from '../types'
2import { isAbsolute } from './home'
3
4// Two questions only: `ahead` was dropped while the status-line band is parked (pre-flight F30).
5export const GIT_ARGV = {
6  branch: ['git', 'symbolic-ref', '--short', 'HEAD'],
7  status: ['git', 'status', '--porcelain'],
8} as const
9
10export const COALESCE_MS = 1500
11
12export type RunOut = { exitCode: number; stdout: string }
13
14export function parseGit(branch: RunOut, status: RunOut): Git {
15  const b = branch.exitCode === 0 ? branch.stdout.trim() || null : null
16  const dirty = status.exitCode === 0
17    ? status.stdout.split('\n').filter(l => l.length > 3 && !l.startsWith('??')).map(l => l.slice(3))
18    : []
19  return { branch: b, dirty }
20}
21
22const TOUCH = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
23export function touchesGit(tool: string): boolean {
24  return TOUCH.has(tool)
25}
26
27// The session's own git dir: in a worktree `<root>/.git` is a file, so HEAD and index live elsewhere.
28export const GIT_DIR_ARGV = ['git', 'rev-parse', '--absolute-git-dir'] as const
29
30/** What to watch for a `git rev-parse --absolute-git-dir` answer; nothing when it failed. */
31export function watchPaths(gitDir: RunOut): string[] {
32  const dir = gitDir.exitCode === 0 ? gitDir.stdout.trim() : ''
33  // git prints forward slashes even on Windows (`C:/repo/.git`)
34  return isAbsolute(dir) ? [`${dir}/HEAD`, `${dir}/index`] : []
35}
36
plugin/core/handover.ts 154 lines
1import type { Fields, Pending, PendingReason, Settings, Snapshot } from '../types'
2import { TOOL_FULL } from './name'
3
4export function nextThreshold(pct: number, s: Pick<Settings, 'nudgeAt' | 'step'>, lastNudged: number | null): number | null {
5  if (pct < s.nudgeAt) return null
6  const crossed = s.nudgeAt + Math.floor((pct - s.nudgeAt) / s.step) * s.step
7  if (lastNudged !== null && crossed <= lastNudged) return null
8  return crossed
9}
10
11// An unattended (auto-mode) handover waits until context has grown at least one step above the
12// session's baseline (its first reading), so a session that starts just under the threshold
13// cannot hand over, clear and hand over again within a turn. Attended nudges ignore this.
14export function grownEnough(pct: number, baseline: number | null, step: number): boolean {
15  return baseline !== null && pct - baseline >= step
16}
17
18// The turn that wrote a handover ends just after the tool call; a turn ending later than this
19// means the conversation moved on and the handover is stale.
20export const REUSE_SLACK_MS = 60_000
21
22// Is a handover still the latest word on the session? No turn has completed since it was written
23// (give or take the slack). A process that has seen no turn yet (a restart, a --resume) knows only
24// the clock: the handover must be young. One rule for reuse (R1-04) and for a manual /clear's
25// automatic resume (R1-10).
26export function fresh(p: Pick<Pending, 'createdAt'>, lastApiAt: number | null, now: number): boolean {
27  return (lastApiAt ?? now) <= p.createdAt + REUSE_SLACK_MS
28}
29
30// A pending handover is reused (cleared into) only when it was written for a clear and is fresh;
31// anything else gets a fresh handover.
32export function reusable(p: Pick<Pending, 'reason' | 'createdAt'> | null, lastApiAt: number | null, now: number): boolean {
33  if (!p || (p.reason !== 'threshold' && p.reason !== 'request')) return false
34  return fresh(p, lastApiAt, now)
35}
36
37export const FIELD_NAMES = ['goal', 'state', 'decisions', 'next_step', 'open_questions', 'failed_attempts', 'session_name'] as const
38const REQUIRED = ['goal', 'state', 'next_step', 'session_name'] as const
39
40const DESCRIBE: Record<(typeof FIELD_NAMES)[number], string> = {
41  goal: 'What this session is trying to achieve, in a sentence or two.',
42  state: 'Where the work stands right now: done, in flight, blocked.',
43  decisions: 'Decisions and rulings made, each with its reason.',
44  next_step: 'The very next concrete action on resume.',
45  open_questions: 'Questions still waiting on the person.',
46  failed_attempts: 'Approaches tried that did not work, and why.',
47  session_name: 'A short name for the session that resumes this work: 2–6 words saying what it will do next (e.g. "vigil-mod: shell handover flow"). It becomes the new session\'s name after the clear.',
48}
49
50export const INPUT_SCHEMA = {
51  type: 'object',
52  properties: Object.fromEntries(FIELD_NAMES.map(n => [n, { type: 'string', description: DESCRIBE[n] }])),
53  required: [...REQUIRED],
54} as const
55
56export const TOOL_DESCRIPTION =
57  'Save a handover for this session so work can resume after the context is cleared. ' +
58  'Call it when context-vigil-mod asks you to, or when the person asks you for a handover. Never write a handover any other way: no ad-hoc files or summaries. ' +
59  'Write for a fresh reader who knows nothing of this conversation.'
60
61export function cleanName(raw: string): string {
62  return raw.replace(/\s+/g, ' ').trim().replace(/^\/+/, '').slice(0, 60).trim()
63}
64
65export function parseFields(input: Record<string, unknown>): { ok: true; fields: Fields } | { ok: false; error: string } {
66  const out: Record<string, string> = {}
67  for (const name of FIELD_NAMES) {
68    const v = input[name]
69    if (v === undefined || v === null) { out[name] = ''; continue }
70    if (typeof v !== 'string') return { ok: false, error: `${name} must be a string` }
71    out[name] = name === 'session_name' ? cleanName(v) : v.trim()
72  }
73  for (const name of REQUIRED) if (!out[name]) return { ok: false, error: `${name} is required and must not be blank` }
74  return { ok: true, fields: out as Fields }
75}
76
77const TITLES: Record<(typeof FIELD_NAMES)[number], string> = {
78  goal: 'Goal', state: 'State', decisions: 'Decisions', next_step: 'Next step',
79  open_questions: 'Open questions', failed_attempts: 'Failed attempts', session_name: 'Session name',
80}
81
82export function renderHandover(f: Fields, s: Snapshot): string {
83  const parts = [`# 📜 Handover — ${s.session} (${s.at})`, `**Next session:** ${f.session_name}`, '']
84  for (const name of FIELD_NAMES) {
85    if (name === 'session_name' || !f[name]) continue
86    parts.push(`## ${TITLES[name]}`, '', f[name], '')
87  }
88  parts.push('## Snapshot', '',
89    `- cwd: ${s.cwd}`,
90    `- branch: ${s.branch ?? '(not a git repo)'}`,
91    `- context: ${s.contextPct === null ? 'unknown' : `${s.contextPct}%`}`,
92    `- dirty: ${s.dirty.length ? s.dirty.join(', ') : 'none'}`,
93    `- edited this session: ${s.edited.length ? s.edited.join(', ') : 'none'}`,
94    '')
95  return parts.join('\n')
96}
97
98const WHY: Record<PendingReason, string> = {
99  threshold: 'Context is past the handover threshold.',
100  request: 'The person asked for a handover.',
101  last_light: 'The session has gone idle and the prompt cache is about to go cold. Do not clear; just save the handover.',
102  limit: 'A usage limit is close. Save the handover so work can resume after the reset.',
103}
104
105export function instructionText(reason: PendingReason): string {
106  return `[context-vigil-mod] ${WHY[reason]} Call the ${TOOL_FULL} tool now with a complete handover ` +
107    '(goal, state, decisions, next step, open questions, failed attempts, session name). Do nothing else this turn.'
108}
109
110export function resumeText(path: string): string {
111  return `[context-vigil-mod] Resume from the handover injected above (saved at ${path}). Start with its next step.`
112}
113
114export function injectText(markdown: string): string {
115  return `[context-vigil-mod] Handover from before the clear:\n\n${markdown}`
116}
117
118// After a limit early stop there was no clear: the conversation is still here.
119export function limitResumeText(path: string | null): string {
120  if (path === null) {
121    return '[context-vigil-mod] The usage limit has reset. No handover was written before the stop (it was deferred or classic was active); pick up from the conversation as it stands.'
122  }
123  return `[context-vigil-mod] The usage limit has reset. Continue the work; the handover you wrote is saved at ${path} if you need it.`
124}
125
126// Any mention of a handover at all: the looser net behind the tool (a person who said "handover" and
127// then had the model call the tool meant it).
128export function mentionsHandover(text: string): boolean {
129  return /hand[ -]?over|hand(?:ing)?[ -]?off/i.test(text)
130}
131
132const REQUEST_MAX_WORDS = 12
133const REQUEST_CORE = /\bhand[ -]?over\b|\bhand(?:ing)?[ -]?off\b|\bhand\s+(?:this|it|that|things|everything|us|work|session|this session)\s+(?:over|off)\b|\bhand\s+this\s+session\s+(?:over|off)\b/
134const REQUEST_NEGATION = /\b(?:don'?t|do not|dont|never|no|not|stop|cancel|without|skip|instead|isn'?t|won'?t|can'?t)\b/
135// Questions about handovers, and talk about the mod's code, files and behaviour.
136const REQUEST_ABOUT = /\b(?:how|what|what'?s|whats|why|when|where|which|who|does|did|is|are|was|were|has|have|bug|bugs|fix|fixing|fixed|broken|broke|fail|fails|failed|failing|wrong|work|works|working|implement|code|file|files|test|tests|spec|docs?|document|explain|show|read|review|check|debug|issue|error|last|previous|latest|update|improve|refactor|think|tool|mod|hook|slow|empty|status|summary|details?|info|information|list|history|log|logs|count|size|path|location|contents?|diff|name)\b/
137const REQUEST_CONDITION = /\b(?:if|unless|after|before|once|whenever|until)\b/
138const REQUEST_LEAD = new Set([
139  'handover', 'handoff', 'hand', 'do', 'run', 'start', 'begin', 'make', 'write', 'create', 'give', 'trigger', 'initiate', 'time', "let's", 'lets', 'let',
140  'go', 'ok', 'okay', 'alright', 'right', 'so', 'now', 'please', 'pls', 'can', 'could', 'would', 'will', 'shall', 'i', 'we', "it's", 'its', 'ready',
141  'need', 'needs', 'want', 'yes', 'yep', 'yeah', 'sure', 'just', 'then', 'kindly', 'hey', 'hi',
142])
143
144/** Is this short human message a request to hand over now (not a question or talk about handovers)? */
145export function isHandoverRequest(text: string): boolean {
146  const clean = text.toLowerCase().replace(/[.!?,;:-]+/g, ' ').replace(/\s+/g, ' ').trim()
147  if (!clean || clean.startsWith('/')) return false
148  const words = clean.split(' ')
149  if (words.length > REQUEST_MAX_WORDS) return false
150  if (!REQUEST_CORE.test(clean)) return false
151  if (REQUEST_NEGATION.test(clean) || REQUEST_ABOUT.test(clean) || REQUEST_CONDITION.test(clean)) return false
152  return REQUEST_LEAD.has(words[0]!)
153}
154
plugin/core/home.ts 76 lines
1// Where "home" and the config dir are, on macOS, Linux and Windows. THE SAME FILE lives in census-mod, context-vigil-mod
2// and agent-roster (plugins share no code); tests/census/test_home_copies.py fails if the copies differ.
3//
4// Rule: the config dir is CLAUDE_CONFIG_DIR, else <home>/.claude; <home> is HOME, else USERPROFILE, else
5// HOMEDRIVE+HOMEPATH. `~`, `~/x` and `~\x` expand with the same home.
6
7export type HomeEnv = {
8  CLAUDE_CONFIG_DIR?: string
9  HOME?: string
10  USERPROFILE?: string
11  HOMEDRIVE?: string
12  HOMEPATH?: string
13}
14
15/** What was looked at, for messages: name the variables actually checked. */
16export const HOME_VARS_CHECKED = 'CLAUDE_CONFIG_DIR, HOME, USERPROFILE and HOMEDRIVE+HOMEPATH'
17
18const nonEmpty = (v: string | undefined): string | undefined => (v && v.trim() ? v : undefined)
19
20/** The home dir: HOME, else USERPROFILE, else HOMEDRIVE+HOMEPATH; null when none is set. */
21export function homeOf(env: HomeEnv): string | null {
22  const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE)
23  if (home) return home
24  const drive = nonEmpty(env.HOMEDRIVE)
25  const path = nonEmpty(env.HOMEPATH)
26  return drive && path ? `${drive}${path}` : null
27}
28
29/** The separator a base path uses: a backslash when it has one and no slash (`C:\Users\x`), else a slash. */
30export const sepOf = (p: string): '/' | '\\' => (p.includes('\\') && !p.includes('/') ? '\\' : '/')
31
32/** Whether a path is absolute on either platform: `/x`, `C:\x`, `C:/x`, `\\server\share`. */
33export const isAbsolute = (p: string): boolean => p.startsWith('/') || p.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(p)
34
35/**
36 * Trailing separators dropped (slash or backslash), the root kept: `/a/b//` -> `/a/b`, `/` -> `/`,
37 * `C:\Users\x\` -> `C:\Users\x`, `C:\` -> `C:\`.
38 */
39export function trimSeps(p: string): string {
40  if (/^[A-Za-z]:$/.test(p)) return p // `C:` is the drive-relative cwd of C:, not the root `C:\`
41  if (/^[A-Za-z]:[\\/]+$/.test(p)) return `${p.slice(0, 2)}${p.includes('/') ? '/' : '\\'}`
42  const t = p.replace(/[\\/]+$/, '')
43  return t || (/^[\\/]/.test(p) ? p[0] ?? '/' : p)
44}
45
46/** `base` + parts, joined with the separator the base uses, so `C:\Users\x` + `.claude` stays all backslashes. */
47export function joinPath(base: string, ...parts: string[]): string {
48  const sep = sepOf(base)
49  const root = trimSeps(base)
50  const tail = parts.map(p => p.replace(/^[\\/]+|[\\/]+$/g, '')).filter(Boolean).join(sep)
51  const joined = root.endsWith('/') || root.endsWith('\\') || /^[A-Za-z]:$/.test(root) ? `${root}${tail}` : `${root}${sep}${tail}`
52  return tail ? joined.replace(sep === '\\' ? /\//g : /\\/g, sep) : root
53}
54
55/** `~`, `~/x` or `~\x` with the home dir (in the home's own separator); anything else unchanged. */
56export function expandHome(p: string, env: HomeEnv): string {
57  const home = homeOf(env)
58  if (!home || !(p === '~' || p.startsWith('~/') || p.startsWith('~\\'))) return p
59  return p === '~' ? trimSeps(home) : joinPath(home, p.slice(2))
60}
61
62/** The config dir: CLAUDE_CONFIG_DIR, else <home>/.claude; null when neither can be told. */
63export function configRootOf(env: HomeEnv): string | null {
64  const set = nonEmpty(env.CLAUDE_CONFIG_DIR)
65  if (set) return trimSeps(set)
66  const home = homeOf(env)
67  return home ? joinPath(home, '.claude') : null
68}
69
70/** A path as a comparison key: one separator, no trailing one, lower-cased when it is a Windows (drive or UNC) path. */
71export function pathKey(p: string): string {
72  const flat = p.replace(/\\/g, '/')
73  const trimmed = flat.length > 1 ? flat.replace(/\/+$/, '') || '/' : flat
74  return /^[A-Za-z]:/.test(p) || p.startsWith('\\\\') ? trimmed.toLowerCase() : trimmed
75}
76
plugin/core/cache-ttl.ts 63 lines
1// Which prompt-cache lifetime the session's main conversation is writing — PROBES §11.
2// Asked once, when last light is about to act. 'unknown' (nothing found) never fires: warming
3// a cold 5-minute cache is the opposite of what last light is for.
4import { joinPath } from './home'
5
6export type CacheTtl = '1h' | '5m' | 'unknown'
7export type Writes = { h1: number; m5: number }
8
9// Reads the usage split off the end of the transcript without ever loading a whole row: grep -o
10// prints only each row's `cache_creation` object. Positional args: $1 = transcript path.
11export const TAIL_CMD = 'tail -c 65536 "$1" | grep -o \'"cache_creation":{[^}]*}\''
12
13/** The last `bytes` UTF-8 bytes of the text (as `tail -c` takes them), not the last UTF-16 units: a cut mid-character is dropped. */
14export function tailBytes(text: string, bytes: number): string {
15  let used = 0
16  let i = text.length
17  while (i > 0) {
18    const code = text.charCodeAt(i - 1)
19    const isLow = code >= 0xdc00 && code <= 0xdfff && i > 1
20    const size = isLow ? 4 : code < 0x80 ? 1 : code < 0x800 ? 2 : 3
21    if (used + size > bytes) break
22    used += size
23    i -= isLow ? 2 : 1
24  }
25
26  return text.slice(i)
27}
28
29/** What TAIL_CMD prints, from the file's text: for where there is no sh/tail/grep (Windows). */
30export function cacheLinesFromText(text: string, bytes = 65536): string {
31  return (tailBytes(text, bytes).match(/"cache_creation":\{[^}]*\}/g) ?? []).join('\n')
32}
33
34const field = (line: string, key: string): number => {
35  const m = new RegExp(`"ephemeral_${key}_input_tokens":(\\d+)`).exec(line)
36  return m ? Number.parseInt(m[1] ?? '0', 10) : 0
37}
38
39// The split of the LAST response that wrote to the cache. A pure cache read writes nothing and
40// says nothing about the lifetime, so it is skipped. null = no write in the tail.
41export function parseWrites(stdout: string): Writes | null {
42  const lines = stdout.split('\n')
43  for (let i = lines.length - 1; i >= 0; i--) {
44    const line = lines[i] ?? ''
45    if (!line.includes('"cache_creation"')) continue
46    const w = { h1: field(line, '1h'), m5: field(line, '5m') }
47    if (w.h1 > 0 || w.m5 > 0) return w
48  }
49  return null
50}
51
52// Any 5m write makes it 5m: content written for five minutes goes cold first, and the cost of
53// warming a cold cache is worse than the cost of skipping a warm one. No write found: unknown.
54export function ttlFromWrites(w: Writes | null): CacheTtl {
55  if (w === null) return 'unknown'
56  return w.m5 > 0 ? '5m' : '1h'
57}
58
59// Where Claude Code keeps a session's transcript, for when no event has carried the path yet.
60export function transcriptPathFor(configRoot: string, cwd: string, sessionId: string): string {
61  return joinPath(configRoot, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'), `${sessionId}.jsonl`)
62}
63
plugin/core/last-light.ts 40 lines
1import { classifyOrigin } from './arming'
2
3export const LEAD_MS = 300_000
4export const TTL_1H = 3_600_000
5
6// Scheduled on the assumption of a 1-hour cache; the fire checks the assumption (PROBES §11).
7export function fireAt(lastApiAt: number): number {
8  return lastApiAt + TTL_1H - LEAD_MS
9}
10
11export type FireFacts = {
12  enabled: boolean
13  youIdle: boolean     // nothing from the person since the agent's last API activity
14  agentIdle: boolean   // no step in the last WORKING_MS
15  contextPct: number | null
16  threshold: number
17  pending: boolean
18  latched: boolean
19  armed: boolean
20}
21
22export function shouldFire(f: FireFacts):
23  { fire: true } | { fire: false; reason: 'off' | 'not-idle' | 'small' | 'pending' | 'latched' | 'disarmed' } {
24  if (!f.enabled) return { fire: false, reason: 'off' }
25  if (!f.youIdle || !f.agentIdle) return { fire: false, reason: 'not-idle' }
26  if (f.contextPct === null || f.contextPct < f.threshold) return { fire: false, reason: 'small' }
27  if (f.pending) return { fire: false, reason: 'pending' }
28  if (f.latched) return { fire: false, reason: 'latched' }
29  if (!f.armed) return { fire: false, reason: 'disarmed' }
30  return { fire: true }
31}
32
33export function rearm(prev: boolean, origin: string): boolean {
34  return classifyOrigin(origin) === 'human' ? true : prev
35}
36
37export function holdOnReturn(f: { pendingIsLastLight: boolean; origin: string; now: number; cacheExpiresAt: number | null }): boolean {
38  return f.pendingIsLastLight && classifyOrigin(f.origin) === 'human' && f.cacheExpiresAt !== null && f.now >= f.cacheExpiresAt
39}
40
plugin/core/surfaces.ts 42 lines
1import type { RcAnswer } from '../types'
2import type { WaitReason } from './voice'
3
4export const COUNTDOWN_MS = 30_000
5export const HOLDBACK_MS = 120_000
6export const RECHECK_MS = 2_000
7
8export type GateFacts = {
9  now: number
10  draft: string
11  onPhone: boolean
12  lastBridgeAt: number | null
13  rcAutoClear: RcAnswer
14  latched: boolean
15  countdownEndsAt: number | null
16  classicActive: boolean
17  unattended: boolean
18}
19
20export type Gate = { go: true } | { go: false; reason: WaitReason; recheckMs: number | null }
21
22export function clearGate(f: GateFacts): Gate {
23  if (f.classicActive) return { go: false, reason: 'classic', recheckMs: null }
24  if (f.latched) return { go: false, reason: 'latched', recheckMs: null }
25  if (f.draft.trim()) return { go: false, reason: 'draft', recheckMs: RECHECK_MS }
26  if (f.onPhone && f.unattended) {
27    // Unanswered follows the terminal (auto is on, so allowed), safeguards included: the question is asked in setup, never mid-run.
28    if (f.rcAutoClear === 'no') return { go: false, reason: 'rc-declined', recheckMs: null }
29    if (f.lastBridgeAt !== null && f.now - f.lastBridgeAt < HOLDBACK_MS) {
30      return { go: false, reason: 'rc-holdback', recheckMs: HOLDBACK_MS - (f.now - f.lastBridgeAt) }
31    }
32    if (f.countdownEndsAt === null) return { go: false, reason: 'countdown-start', recheckMs: COUNTDOWN_MS }
33    if (f.now < f.countdownEndsAt) return { go: false, reason: 'countdown', recheckMs: f.countdownEndsAt - f.now }
34  }
35  return { go: true }
36}
37
38// The one-per-session hint (never a question) when auto mode arms on the phone with no answer yet.
39export function needsRcHint(onPhone: boolean, rc: RcAnswer, wouldArm: boolean): boolean {
40  return onPhone && wouldArm && rc === 'unanswered'
41}
42