SLOPSHOPPER

census-mod

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

newbandspinnerguardcommandtoast
v0.0.0MITupdated 2026-10-10ppryde/pip-skills/plugins/census-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · census-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /census-setup ⎿ census-mod: 🧭 census-setup: a few questions follow. 🧠 •••••ᗧ•••• 49% │ 🎯 93% ⟳ 4m │ 💸 ᗧ$$$$$$$$$ $0.42 │ 🐌 $0.84/hr ✻ Opus 5.5 │ 📁 /work/app ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🧠 •••••ᗧ•••• 49% │ 🎯 93% ⟳ 4m │ 💸 ᗧ$$$$$$$$$ $0.42 │ 🐌 $0.84/hr ✻ Opus 5.5 │ 📁 /work/app ⟨Claude Code's own drawing⟩
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 13 files
plugin/hooks/register.tsx 1028 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { COUNTER_KEEP_MS, EMPTY_COUNTERS, STORE_PREFIX, TAIL_CMD, addTurn, compacted, counterKey, DEFAULT_TTL, expiresAtMs, isWarm, parseWrites, ttlFromWrites, ttlMs, withTtl } from '../core/cache'
3import { INGEST_TIMEOUT_MS, bundledCli, censusDir, delayFor, endTimeoutMs, ingestArgv, ingestEnv } from '../core/census'
4import type { CensusEnv } from '../core/census'
5import { GH_TIMEOUT_MS, ghArgv, ghKey, parsePrList, shouldRefresh, touchesPr } from '../core/gh'
6import type { GhEntry, Why } from '../core/gh'
7import { COALESCE_MS as GIT_COALESCE_MS, GIT_DIR_ARGV, GIT_STATUS_ARGV, parseStatus, touchesGit, watchPaths, worktreeOf } from '../core/git'
8import { homeOf, joinPath } from '../core/home'
9import { configRoot, transcriptPathFor } from '../core/name'
10import { PYTHON_CANDIDATES, cacheLinesFromText, copyModeArgv, moveArgv, pickPython, removeArgv, titleLinesFromText } from '../core/portable'
11import { buildPayload, modelOf, rateLimitsOf } from '../core/payload'
12import type { Event } from '../core/payload'
13import { TITLE_ARGV, TITLE_TAIL_CMD, findProc, lastTitle } from '../core/registry'
14import { TONE_COLOR, draw, fit } from '../core/render'
15import { BACKUP_FILE, backupBlocks, placementFrom, replaceFrom, L, NO_DETECTION, PRESETS, Q, SETUP_KEY, commandIsCensus, continueAnyway, enabledCensusPlugins, effective, hasIngestBlock, parseSettings, presetFrom, recordFrom, removeStatusLine, restoreStatusLine, scriptCandidates, settingsTmp, statusLineCommand, writerActive, writersFrom, is } from '../core/setup'
16import type { Detection, Effective, Saved } from '../core/setup'
17import type { Line, RenderEnv, RenderInput } from '../core/render'
18import type { Counters, RateLimit, Snap } from '../core/types'
19
20// The effectful shell: the ONLY file that touches `$`. Decisions live in ../core.
21//
22// State is module variables plus $.store (counters, gh cache): a hot reload loses the variables, and
23// session.start (which re-fires on a reload) rebuilds them. Nothing is kept in $.state, so a /clear
24// has nothing to wipe and classic.SessionStart(clear) simply binds the new session.
25
26type Env = CensusEnv & RenderEnv & { USERPROFILE?: string; CENSUS_MOD_PLACEMENT?: string }
27
28let env: Env = {} // what the mod runs on: the environment, then the answers laid over it
29let rawEnv: Env = {} // the environment alone: it outranks an answer
30let saved: Saved = {}
31let det: Detection = NO_DETECTION
32let eff: Effective = effective({}, {}, NO_DETECTION, null)
33let setupRun: object | null = null
34const titleScanned = new WeakMap<Snap, string>() // snapshot -> the transcript path whose whole file was scanned
35let offerTimer: Timer | null = null
36let interactive: boolean | null = null
37let snap: Snap | null = null
38let hydrating: Promise<void> | null = null
39let gitReady: Promise<void> | null = null // the first `git status` of the bound session, so the first write carries it
40let gitReadyDone: (() => void) | null = null
41const ended = new Set<string>()
42let lastActiveAt: number | null = null
43let limitsKey = ''
44let ingestTimer: Timer | null = null
45let lastIngestAt: number | null = null
46let pending: Event | null = null
47let coldTimer: Timer | null = null
48let tickTimer: Timer | null = null
49let gitTimer: Timer | null = null
50let lastGitAt: number | null = null
51let cli: string | null | undefined // census-mod's own bundled recorder, once found there
52let saidNoCli = false
53let python: string[] | null | undefined // the launcher that ran --version, once; null = none found
54let shell: boolean | undefined // whether `sh` runs here (it does not on Windows)
55let saidNoGh = false
56
57const TICK_MS = 30_000
58
59async function nowMs($: EngineInterface): Promise<number> {
60  return $.clock.now()
61}
62
63async function loadEnv($: EngineInterface): Promise<Env> {
64  return {
65    CENSUS_MOD_STORE: await $.env.get('CENSUS_MOD_STORE'),
66    CENSUS_STORE: await $.env.get('CENSUS_STORE'),
67    CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
68    HOME: await $.env.get('HOME'),
69    USERPROFILE: await $.env.get('USERPROFILE'),
70    HOMEDRIVE: await $.env.get('HOMEDRIVE'),
71    HOMEPATH: await $.env.get('HOMEPATH'),
72    CENSUS_STATUSLINE_SEGMENTS: await $.env.get('CENSUS_STATUSLINE_SEGMENTS'),
73    CENSUS_MOD_PLACEMENT: await $.env.get('CENSUS_MOD_PLACEMENT'),
74    CENSUS_STATUSLINE_MASCOT: await $.env.get('CENSUS_STATUSLINE_MASCOT'),
75    CLAUDE_COST_BUDGET: await $.env.get('CLAUDE_COST_BUDGET'),
76    CLAUDE_PROFILE: await $.env.get('CLAUDE_PROFILE'),
77  }
78}
79
80function fresh(id: string, cwd: string): Snap {
81  return {
82    sessionId: id, transcriptPath: null, cwd, worktreePath: null, version: null, model: null, sessionName: null,
83    ctxPct: null, ctxWindow: null, costUsd: null, startedAt: null, rateLimits: [],
84    counters: EMPTY_COUNTERS, ttl: DEFAULT_TTL, git: null, pr: null, proc: null,
85  }
86}
87
88const repaint = ($: EngineInterface) => void $.ui.invalidate('ui.render')
89
90// ---- engine facts -------------------------------------------------------------------------
91
92async function refreshEngine($: EngineInterface) {
93  if (!snap) return
94  const usage = await $.session.usage().catch(() => null)
95  if (usage) {
96    snap.ctxPct = usage.context.percent ?? null
97    snap.ctxWindow = usage.context.window ?? null
98    snap.costUsd = usage.cost?.usd ?? null
99    snap.startedAt = usage.startedAt
100    snap.rateLimits = usage.rateLimits as RateLimit[]
101    limitsKey = limitsFingerprint(snap.rateLimits)
102  }
103  const model = await $.session.model().catch(() => null)
104  if (model) snap.model = modelOf(model)
105}
106
107const limitsFingerprint = (l: RateLimit[]): string => l.map(x => `${x.kind}:${x.percentUsed}:${x.resetsAt ?? ''}`).sort().join('|')
108
109async function locateProc($: EngineInterface) {
110  if (!snap || snap.proc) return // looked for again on every write until found: it is one small read
111  try {
112    const root = configRoot(env)
113    if (!root) return
114    const sessions = joinPath(root, 'sessions')
115    const entries = await $.fs.list(sessions)
116    const files: { text: string }[] = []
117    for (const f of entries) {
118      if (!f.name.endsWith('.json')) continue
119      const text = await $.fs.read(joinPath(sessions, f.name)).catch(() => undefined)
120      if (typeof text === 'string') files.push({ text })
121    }
122    const proc = findProc(files, snap.sessionId)
123    if (proc) {
124      snap.proc = proc
125      snap.version = proc.version ?? snap.version
126    }
127  } catch {
128    // the registry is a nicety for liveness; recording goes on without it
129  }
130}
131
132/**
133 * The session's title from its transcript. Whole file once (at bind); after that only the tail, where a later
134 * /rename lands, so a long session is not re-read end to end on every turn. Applied to `s`, the snapshot the
135 * read was asked for, never to whatever the module holds by the time the read returns.
136 */
137async function readName($: EngineInterface, s: Snap | null = snap, whole = false) {
138  if (!s?.transcriptPath) return
139  try {
140    if (!(await hasShell($))) {
141      s.sessionName = lastTitle(titleLinesFromText(await transcriptText($, s.transcriptPath), whole ? undefined : 262144)) ?? s.sessionName
142      return
143    }
144    const argv = whole ? TITLE_ARGV(s.transcriptPath) : ['sh', '-c', TITLE_TAIL_CMD, 'sh', s.transcriptPath]
145    const r = await $.process.run(argv)
146    if (r.exitCode === 0) s.sessionName = lastTitle(r.stdout) ?? s.sessionName
147  } catch {
148    // keep the last name
149  }
150}
151
152async function readTtl($: EngineInterface, s: Snap | null = snap) {
153  if (!s?.transcriptPath) return
154  try {
155    const out = (await hasShell($))
156      ? await $.process.run(['sh', '-c', TAIL_CMD, 'sh', s.transcriptPath]).then(r => (r.exitCode === 0 ? r.stdout : ''))
157      : cacheLinesFromText(await transcriptText($, s.transcriptPath))
158    const found = out ? ttlFromWrites(parseWrites(out)) : null
159    if (found) {
160      s.ttl = found
161      s.counters = withTtl(s.counters, found)
162    }
163  } catch {
164    // keep the last known (default 5m)
165  }
166}
167
168// ---- counters (survive a reload or restart) ------------------------------------------------
169
170async function loadCounters($: EngineInterface, id: string): Promise<Counters> {
171  const stored = (await $.store.get(counterKey(id)).catch(() => undefined)) as Counters | undefined
172  return stored && typeof stored.requests === 'number' ? { ...EMPTY_COUNTERS, ...stored } : EMPTY_COUNTERS
173}
174
175async function saveCounters($: EngineInterface, s: Snap | null = snap) {
176  if (s) await $.store.set(counterKey(s.sessionId), s.counters).catch(() => undefined)
177}
178
179async function pruneCounters($: EngineInterface, keep: string, now: number) {
180  try {
181    for (const key of await $.store.keys()) {
182      if (!key.startsWith(STORE_PREFIX) || key === counterKey(keep)) continue
183      const c = (await $.store.get(key)) as Counters | undefined
184      if (!c || now - (c.updatedAt ?? 0) > COUNTER_KEEP_MS) await $.store.delete(key)
185    }
186  } catch {
187    // housekeeping only
188  }
189}
190
191// ---- recording -----------------------------------------------------------------------------
192
193/** census-mod records through its own bundled copy of census's ingest: no census plugin is ever needed or looked for. */
194async function discover($: EngineInterface): Promise<string | null> {
195  if (cli) return cli
196  let root: string | undefined
197  try {
198    root = $.plugin.root
199  } catch {
200    root = undefined
201  }
202  const path = root ? bundledCli(root) : null
203  if (path && (await $.fs.exists(path).catch(() => false))) return (cli = path)
204  if (!saidNoCli) {
205    saidNoCli = true
206    $.ui.log('census-mod\'s bundled recorder is missing (scripts/cli.py): reinstall census-mod. The band is drawn, nothing is recorded')
207  }
208  return null
209}
210
211/** `python3`, `python` or `py -3`, the first that runs `--version`; asked once per process. null: none, said once in the log. */
212async function pythonLauncher($: EngineInterface, budgetMs = INGEST_TIMEOUT_MS): Promise<string[] | null> {
213  if (python !== undefined) return python
214  // Three probes share the hook's budget (a session.end has little): too little left, and the probe waits for a later ingest.
215  const probeMs = Math.min(2000, Math.floor(budgetMs / 6))
216  if (probeMs < 200) return null
217  const found = await pickPython(async argv => (await $.process.run(argv, { timeoutMs: probeMs })).exitCode === 0)
218  python = found
219  if (!python) $.ui.log('census-mod found no Python (tried python3, python, py -3): the band is drawn, nothing is recorded')
220  return python
221}
222
223/** Whether `sh` runs here; asked once per process. */
224async function hasShell($: EngineInterface): Promise<boolean> {
225  if (shell === undefined) shell = await $.process.run(['sh', '-c', 'exit 0'], { timeoutMs: 5000 }).then(r => r.exitCode === 0, () => false)
226  return shell
227}
228
229/** The transcript's text (a file over the engine's read cap reads as nothing): for where there is no sh/tail/grep. */
230let lastRead: { path: string; at: number; text: Promise<string> } | undefined
231const transcriptText = async ($: EngineInterface, path: string): Promise<string> => {
232  // The TTL and the title are read together every turn: one read serves both.
233  const now = await nowMs($)
234  if (lastRead && lastRead.path === path && now - lastRead.at < 2000) return lastRead.text
235  const text = Promise.resolve($.fs.read(path)).then(t => (typeof t === 'string' ? t : ''), () => '')
236  lastRead = { path, at: now, text }
237
238  return text
239}
240
241async function ingest($: EngineInterface, event: Event, endedReason?: string, timeoutMs = INGEST_TIMEOUT_MS, of: Snap | null = snap) {
242  if (!of) return
243  if (eff.record === 'no') return
244  const path = await discover($)
245  if (!path) return
246  const py = await pythonLauncher($, timeoutMs)
247  const payload = buildPayload(of, await nowMs($), event, endedReason)
248  const run = (launcher: readonly string[]) => $.process.run(ingestArgv(path, launcher), { stdin: JSON.stringify(payload), env: ingestEnv(env), timeoutMs })
249  try {
250    if (!py) {
251      // Not chosen yet and too little budget to probe (a session.end has about a second, and no later ingest): run the
252      // ingest itself through each launcher in turn. One that cannot be spawned (or is missing: 127, 9009) is skipped.
253      if (python !== undefined) return
254      for (const candidate of PYTHON_CANDIDATES) {
255        const out = await run(candidate).catch(() => undefined)
256        if (!out || out.exitCode === 127 || out.exitCode === 9009) continue
257        python = [...candidate]
258        if (out.exitCode !== 0) cli = undefined
259        return
260      }
261      return
262    }
263    const out = await run(py)
264    if (out.exitCode !== 0) cli = undefined // the bundle moved or broke: look again next time
265  } catch {
266    cli = undefined
267  }
268}
269
270/** Ask for an ingest. At most one per COALESCE_MS; the one that runs carries the latest state. */
271function record($: EngineInterface, event: Event) {
272  if (!interactive || !snap) return
273  pending = event
274  if (ingestTimer) return
275  void (async () => {
276    const wait = delayFor(await nowMs($), lastIngestAt)
277    if (ingestTimer) return
278    ingestTimer = $.clock.after(wait, () => void flush($).catch(() => undefined))
279  })()
280}
281
282async function flush($: EngineInterface) {
283  ingestTimer = null
284  const event = pending
285  pending = null
286  if (!event || !snap) return
287  lastIngestAt = await nowMs($)
288  await hydrating
289  // The first write waits (briefly) for the first git status, or census_mod.git would be null until the next one.
290  if (gitReady) await Promise.race([gitReady, new Promise<void>(done => $.clock.after(3000, done))])
291  await refreshEngine($)
292  await locateProc($)
293  await ingest($, event)
294}
295
296// ---- git and gh ----------------------------------------------------------------------------
297
298function scheduleGit($: EngineInterface) {
299  if (!interactive || gitTimer) return
300  void (async () => {
301    const wait = lastGitAt === null ? 0 : Math.max(0, lastGitAt + GIT_COALESCE_MS - (await nowMs($)))
302    if (gitTimer) return
303    gitTimer = $.clock.after(wait, () => void runGit($).catch(() => undefined))
304  })()
305}
306
307async function runGit($: EngineInterface) {
308  gitTimer = null
309  if (!snap) return
310  lastGitAt = await nowMs($)
311  const cwd = snap.cwd
312  const r = await $.process.run(GIT_STATUS_ARGV, { cwd }).catch(() => undefined)
313  const first = gitReadyDone
314  gitReadyDone = null
315  first?.()
316  if (!snap || snap.cwd !== cwd) return
317  const before = snap.git
318  snap.git = r && r.exitCode === 0 ? parseStatus(r.stdout) : null
319  if (before?.branch !== snap.git?.branch) {
320    snap.pr = null
321    // The first sight of a branch is not a change of it.
322    if (before !== null) record($, 'branch')
323    scheduleGh($, before === null ? 'start' : 'branch')
324  }
325  repaint($)
326}
327
328function scheduleGh($: EngineInterface, why: Why) {
329  if (!interactive || !eff.pr) return
330  $.clock.after(0, () => void refreshGh($, why).catch(() => undefined))
331}
332
333async function refreshGh($: EngineInterface, why: Why) {
334  if (!eff.pr) return // answered No: gh is never called
335  await hydrating // the worktree path gh runs in is read there
336  if (!snap?.git?.branch || snap.git.detached) return
337  const key = ghKey(snap.worktreePath ?? snap.cwd, snap.git.branch)
338  const branch = snap.git.branch
339  const now = await nowMs($)
340  const cached = (await $.store.get(key).catch(() => undefined)) as GhEntry | undefined
341  if (cached) snap.pr = cached.pr
342  if (!shouldRefresh(why, cached, now, lastActiveAt)) return
343  const before = snap.pr
344  let entry: GhEntry
345  try {
346    const r = await $.process.run(ghArgv(branch), { cwd: snap.worktreePath ?? snap.cwd, timeoutMs: GH_TIMEOUT_MS })
347    const pr = r.exitCode === 0 ? parsePrList(r.stdout) : undefined
348    entry = pr === undefined ? { at: cached?.at ?? 0, pr: cached?.pr ?? null, failedAt: now } : { at: now, pr }
349  } catch {
350    if (!saidNoGh) {
351      saidNoGh = true
352      $.ui.log('gh unavailable: no PR segment (is gh installed and logged in?)')
353    }
354    entry = { at: cached?.at ?? 0, pr: cached?.pr ?? null, failedAt: now }
355  }
356  await $.store.set(key, entry).catch(() => undefined)
357  if (!snap || snap.git?.branch !== branch) return
358  snap.pr = entry.pr
359  if (before?.number !== entry.pr?.number || before?.reviewState !== entry.pr?.reviewState) record($, 'pr')
360  repaint($)
361}
362
363async function readGitDir($: EngineInterface, cwd: string) {
364  return $.process.run(GIT_DIR_ARGV, { cwd }).then(r => ({ exitCode: r.exitCode, stdout: r.stdout })).catch(() => ({ exitCode: 1, stdout: '' }))
365}
366
367// ---- lifecycle -----------------------------------------------------------------------------
368
369function cancelTimers() {
370  for (const t of [ingestTimer, coldTimer, tickTimer, gitTimer, offerTimer]) t?.cancel()
371  ingestTimer = coldTimer = tickTimer = gitTimer = offerTimer = null
372}
373
374function armCold($: EngineInterface) {
375  coldTimer?.cancel()
376  coldTimer = null
377  if (!snap) return
378  const at = expiresAtMs(snap.counters, snap.ttl)
379  if (at === null || snap.counters.cold) return
380  void (async () => {
381    const wait = at - (await nowMs($))
382    if (wait <= 0 || !snap) return
383    coldTimer = $.clock.after(wait, () => {
384      coldTimer = null
385      repaint($)
386      record($, 'cache.cold')
387    })
388  })()
389}
390
391function armTimers($: EngineInterface) {
392  tickTimer?.cancel()
393  // Countdowns only: redraws, never records.
394  tickTimer = $.clock.every(TICK_MS, () => {
395    repaint($)
396    scheduleGh($, 'age')
397  })
398  armCold($)
399}
400
401/** The git dir (worktree) and the session name: shell-outs kept off the start hooks' path. The first write waits for them. */
402function hydrate($: EngineInterface, known?: { exitCode: number; stdout: string }) {
403  hydrating = new Promise<void>(done => {
404    $.clock.after(0, () => {
405      void (async () => {
406        await loadSetup($)
407        if (!snap) return
408        const gitDir = known ?? (await readGitDir($, snap.cwd))
409        if (snap) snap.worktreePath = worktreeOf(gitDir)
410        const scanned = snap ? titleScanned.get(snap) : undefined
411        if (snap) titleScanned.set(snap, snap.transcriptPath ?? '')
412        await readName($, snap, scanned !== snap?.transcriptPath)
413        repaint($)
414        offerOnce($)
415      })()
416        .catch(() => undefined)
417        .finally(done)
418    })
419  })
420}
421
422/** An ended record for a session this process is leaving, from its last snapshot; once per session. */
423function closeOld($: EngineInterface, old: Snap | null, why: string) {
424  if (!old || ended.has(old.sessionId)) return
425  ended.add(old.sessionId)
426  $.clock.after(0, () => void ingest($, 'session.end', why, INGEST_TIMEOUT_MS, old).catch(() => undefined))
427}
428
429/** (Re)bind the live state to a session. Idempotent: a reload or a repeated start finds it bound. */
430async function bind($: EngineInterface, id: string, cwd: string, transcript: string | null, event: Event, gitDir?: { exitCode: number; stdout: string }) {
431  const same = snap?.sessionId === id
432  ended.delete(id) // a resumed id is live again
433  if (!same) {
434    const old = snap
435    snap = fresh(id, cwd)
436    limitsKey = ''
437    lastGitAt = null // a new session's first git status is not held back by the old one's
438    if (old && event !== 'session.start') {
439      closeOld($, old, event.replace('session.', ''))
440      setupRun = null // an open setup dialog belongs to the session that is gone
441    }
442    cancelTimers()
443    pending = null
444    snap.counters = await loadCounters($, id)
445    snap.ttl = snap.counters.ttl ?? DEFAULT_TTL
446  }
447  if (!snap) return
448  const root = configRoot(env)
449  snap.transcriptPath = transcript ?? snap.transcriptPath ?? (root ? transcriptPathFor(root, snap.cwd, id) : null)
450  await refreshEngine($)
451  await locateProc($)
452  hydrate($, gitDir)
453  armTimers($)
454  if (!same || !gitReady) gitReady = new Promise<void>(done => { gitReadyDone = done })
455  scheduleGit($)
456  record($, event)
457  void pruneCounters($, id, await nowMs($))
458}
459
460async function isInteractive($: EngineInterface): Promise<boolean> {
461  if (interactive !== null) return interactive
462  return (await $.session.surfaces().catch(() => [])).length > 0
463}
464
465// ---- /census-setup -------------------------------------------------------------------------------------
466//
467// Asks through $.ui.ask, one question at a time: nothing is submitted, nothing reaches the model, and a
468// phone can answer. Each answer is saved the moment it is given, so a /clear or a dismissal loses only
469// what was not yet asked. The precedence is the environment, then these answers, then the defaults.
470
471async function loadSetup($: EngineInterface) {
472  saved = ((await $.store.get(SETUP_KEY).catch(() => undefined)) as Saved | undefined) ?? {}
473  det = await detect($)
474  applyEffective($)
475}
476
477function applyEffective($: EngineInterface) {
478  const before = env.CENSUS_MOD_STORE
479  eff = effective(saved, rawEnv, det, configRoot(rawEnv))
480  env = { ...rawEnv, CENSUS_MOD_STORE: eff.shadowDir ?? undefined, CENSUS_STATUSLINE_SEGMENTS: eff.segments }
481  repaint($)
482}
483
484async function saveAnswer($: EngineInterface, patch: Partial<Saved>) {
485  saved = { ...saved, ...patch }
486  await $.store.set(SETUP_KEY, saved).catch(() => undefined)
487  applyEffective($)
488}
489
490const settingsPath = (): string | null => {
491  const root = configRoot(rawEnv)
492  return root ? joinPath(root, 'settings.json') : null
493}
494const realCensusDir = (): string | null => censusDir(rawEnv)
495
496/** Step 1 of setup, no questions: this account's status line, any other writer, whether the census plugin is enabled. */
497async function detect($: EngineInterface): Promise<Detection> {
498  const out: Detection = { ...NO_DETECTION }
499  try {
500    const path = settingsPath()
501    const text = path ? await $.fs.read(path).catch(() => undefined) : undefined
502    if (typeof text === 'string') {
503      const parsed = parseSettings(text)
504      if (!parsed.ok) out.settingsInvalid = true
505      else {
506        out.censusPlugins = enabledCensusPlugins(parsed.data)
507        const command = statusLineCommand(parsed.data)
508        out.statusLineCommand = command
509        if (command) {
510          out.ingestBlock = commandIsCensus(command)
511          for (const file of scriptCandidates(command, homeOf(rawEnv) ?? undefined, rawEnv.USERPROFILE)) {
512            const script = await $.fs.read(file).catch(() => undefined)
513            if (typeof script === 'string' && hasIngestBlock(script)) out.ingestBlock = true
514          }
515        }
516      }
517    }
518    const dir = realCensusDir()
519    if (dir) {
520      const files = ((await $.fs.list(joinPath(dir, 'sessions')).catch(() => [])) as { name: string; mtimeMs: number }[])
521        .filter(f => f.name.endsWith('.json'))
522        .sort((a, b) => b.mtimeMs - a.mtimeMs)
523        .slice(0, 20)
524      const entries: { updatedAt: number; hasCensusMod: boolean }[] = []
525      for (const f of files) {
526        const body = await $.fs.read(joinPath(dir, 'sessions', f.name)).catch(() => undefined)
527        try {
528          const d = JSON.parse(typeof body === 'string' ? body : '{}') as { updated_at?: number; payload?: { census_mod?: unknown } }
529          if (typeof d.updated_at === 'number') entries.push({ updatedAt: d.updated_at * 1000, hasCensusMod: d.payload?.census_mod !== undefined })
530        } catch {
531          // a file caught mid-write
532        }
533      }
534      out.otherWriter = writerActive(entries, await nowMs($))
535    }
536  } catch {
537    // detection is advice; setup goes on with what it has
538  }
539  return out
540}
541
542function say($: EngineInterface, headline: string, lines: string[] = []) {
543  $.ui.toast(headline)
544  for (const l of [headline, ...lines]) $.ui.log(l)
545}
546
547/** Remove a file. Windows has no argv-only delete, so there it is emptied, and an empty backup reads as none. */
548const removeFile = async ($: EngineInterface, path: string): Promise<void> => {
549  const argv = removeArgv(path)
550  if (argv) await $.process.run(argv).catch(() => undefined)
551  else await $.fs.write(path, '').catch(() => undefined)
552}
553
554/** The backup's text; an emptied one (Windows) is no backup. */
555const readBackup = async ($: EngineInterface, path: string): Promise<string | null> => {
556  const text = (await $.fs.read(path).catch(() => undefined)) as string | undefined
557
558  return typeof text === 'string' && text.trim() ? text : null
559}
560
561const writeSettings = async ($: EngineInterface, path: string, text: string): Promise<boolean> => {
562  // Through a symlink to its target (a dotfiles repo), never replacing the link; a temp file beside the
563  // target, started as a copy so the mode survives, then renamed over it: a reader never sees half a file.
564  const target = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath ?? path
565  const tmp = settingsTmp(target)
566  if (!moveArgv(tmp, target)) {
567    // Windows: no shell to rename with (cmd would parse the path), so the file is written in place.
568    return $.fs.write(target, text).then(() => true, () => false)
569  }
570  try {
571    const copy = copyModeArgv(target, tmp) // keeps the mode on POSIX; a Windows file has none
572    if (copy) await $.process.run(copy).catch(() => undefined)
573    await $.fs.write(tmp, text)
574    const mv = await $.process.run(moveArgv(tmp, target)!)
575    if (mv.exitCode === 0) return true
576  } catch {
577    // fall through to the cleanup
578  }
579  await removeFile($, tmp)
580  return false
581}
582
583/** Remove this account's statusLine for the band to replace; false (and a message) when it was left alone. */
584async function removeOwnStatusLine($: EngineInterface): Promise<{ done: boolean; backup?: string }> {
585  const path = settingsPath()
586  const dir = realCensusDir()
587  if (!path || !dir) return { done: false }
588  const text = await $.fs.read(path).catch(() => undefined)
589  if (typeof text !== 'string') return { done: false }
590  const r = removeStatusLine(text, path, new Date(await nowMs($)).toISOString())
591  if (!r.ok) {
592    if (r.reason === 'invalid') say($, `🧭 census-setup: ${path} is not valid JSON, so I left your status line alone`)
593    return { done: r.reason === 'none' }
594  }
595  const backup = joinPath(dir, BACKUP_FILE)
596  const existing = await readBackup($, backup)
597  if (backupBlocks(existing, r.backup)) {
598    say($, `🧭 census-setup: the backup already holds a different status line (${backup}), so I left your status line alone`)
599    return { done: false }
600  }
601  try {
602    await $.fs.write(backup, r.backup) // first: the removal is undoable before it happens
603  } catch {
604    say($, `🧭 census-setup: could not write the backup ${backup}, so I left your status line alone`)
605    return { done: false }
606  }
607  if (!(await writeSettings($, path, r.text))) {
608    say($, `🧭 census-setup: could not write ${path}, so I left your status line alone`)
609    return { done: false }
610  }
611  return { done: true, backup }
612}
613
614/** `/census-setup off`: stop recording and drawing, and put a status line we removed back exactly. */
615async function turnOff($: EngineInterface) {
616  await saveAnswer($, { record: 'no', draw: false, answeredAt: await nowMs($), offered: true })
617  const lines: string[] = ['recording: off', 'band: off']
618  const path = settingsPath()
619  const dir = realCensusDir()
620  const backupPath = dir ? joinPath(dir, BACKUP_FILE) : null
621  const backup = backupPath ? await readBackup($, backupPath) : null
622  if (path && backupPath && backup !== null) {
623    const text = await $.fs.read(path).catch(() => undefined)
624    const r = restoreStatusLine(typeof text === 'string' ? text : '', backup)
625    if (r.done === 'restored') {
626      if (await writeSettings($, path, r.text)) {
627        await removeFile($, backupPath)
628        lines.push(`your status line is back in ${path}`)
629      } else lines.push(`could not write ${path}: your status line is still backed up in ${backupPath}`)
630    } else if (r.done === 'already') {
631      await removeFile($, backupPath)
632      lines.push('your status line is already in place')
633    } else lines.push(`left ${path} alone (${r.why === 'present' ? 'it has a different status line now' : r.why === 'invalid' ? 'it is not valid JSON' : 'no usable backup'}); the backup stays in ${backupPath}`)
634  }
635  say($, '🧭 census-mod is off', [...lines, 'run /census-setup to turn it on again'])
636}
637
638type Answer = { a: string } | 'dismissed' | 'superseded'
639
640async function askOne($: EngineInterface, run: object, q: { header: string; question: string; options: string[] }): Promise<Answer> {
641  const got = await $.ui.ask(q.question, { header: q.header, options: q.options }).then(a => ({ a: String(a) }), () => null)
642  if (setupRun !== run) return 'superseded' // a /clear or another setup took over while the dialog was open
643  return got ?? 'dismissed'
644}
645
646async function setup($: EngineInterface, run: object, mode: 'full' | 'offer') {
647  await loadSetup($)
648  if (!saved.offered) await saveAnswer($, { offered: true }) // asked, so never offered again, whatever the answer
649  const stop = async (why: Answer) => {
650    if (why === 'dismissed') say($, '🧭 census-setup stopped; what you answered is saved. Run /census-setup to carry on')
651    if (setupRun === run) setupRun = null
652  }
653  if (mode === 'offer') {
654    await saveAnswer($, { offered: true })
655    const a = await askOne($, run, Q.offer())
656    if (typeof a !== 'object' || !is(a.a, L.offerYes)) {
657      if (setupRun === run) setupRun = null // not now, or dismissed: the defaults stand and it is not offered again
658      return
659    }
660  }
661  // step 1, no questions
662  const d = det
663  say($, '🧭 census-setup: looking around', [
664    `status line: ${d.statusLineCommand ?? 'none'}${d.ingestBlock ? ' (it records into census)' : ''}${d.settingsInvalid ? ' (settings.json is not valid JSON)' : ''}`,
665    `another writer on the store: ${d.otherWriter ? 'yes, active in the last few minutes' : 'no'}`,
666    `the census plugin: ${d.censusPlugins.length ? `enabled (${d.censusPlugins.join(', ')})` : 'not enabled'}`,
667  ])
668  // census and census-mod are alternatives: with the census plugin enabled there would be two writers on one store.
669  if (d.censusPlugins.length) {
670    const a = await askOne($, run, Q.exclusive(d.censusPlugins))
671    if (typeof a !== 'object') return stop(a)
672    if (!continueAnyway(a.a)) {
673      say($, `🧭 census-setup: census-mod replaces the census plugin — disable it first: ${d.censusPlugins.map(k => `claude plugin disable ${k}`).join('; ')}. Then run /census-setup again`)
674      if (setupRun === run) setupRun = null
675      return
676    }
677    say($, '🧭 census-setup: continuing with the census plugin still enabled — two writers on one store; expect muddled liveness')
678  }
679  let recordMode: 'yes' | 'shadow' | 'no' | null = null
680  let replace = false
681  if (recordMode === null) {
682    const a = await askOne($, run, Q.record(d, Boolean(rawEnv.CENSUS_MOD_STORE?.trim())))
683    if (typeof a !== 'object') return stop(a)
684    recordMode = recordFrom(a.a) ?? 'no'
685    replace = replaceFrom(a.a)
686    await saveAnswer($, { record: recordMode })
687  }
688  // Replacing the status line means census-mod must draw: only ask where.
689  const band = await askOne($, run, replace ? Q.place() : Q.draw())
690  if (typeof band !== 'object') return stop(band)
691  const placement = placementFrom(band.a)
692  const drawOn = placement !== null
693  await saveAnswer($, placement ? { draw: true, placement } : { draw: false })
694  // CENSUS_MOD_STORE outranks the answer: census-mod records to the shadow store, so the status line is still the
695  // real store's only writer. Removing it would stop that store before the comparison is done.
696  const shadowByEnv = Boolean(rawEnv.CENSUS_MOD_STORE?.trim())
697  const keptForShadow = shadowByEnv && recordMode === 'yes' && d.ingestBlock
698  let removed: string | undefined
699  if (recordMode === 'yes' && d.ingestBlock && !shadowByEnv) {
700    let choice: ReturnType<typeof writersFrom>
701    if (replace && drawOn) choice = 'remove'
702    else {
703      const a = await askOne($, run, Q.writers(drawOn, d.otherWriter))
704      if (typeof a !== 'object') return stop(a)
705      choice = writersFrom(a.a)
706    }
707    if (choice === 'remove') {
708      const r = await removeOwnStatusLine($)
709      if (r.done) {
710        removed = r.backup
711        det = await detect($)
712      } else {
713        recordMode = 'no'
714        await saveAnswer($, { record: recordMode })
715      }
716    } else if (choice === 'keep') {
717      recordMode = 'no'
718      await saveAnswer($, { record: recordMode })
719    }
720  }
721  if (recordMode === 'no' && !drawOn) {
722    await turnOff($)
723    if (setupRun === run) setupRun = null
724    return
725  }
726  let preset = saved.preset
727  if (drawOn) {
728    const a = await askOne($, run, Q.preset())
729    if (typeof a !== 'object') return stop(a)
730    preset = presetFrom(a.a) ?? 'two'
731    await saveAnswer($, { preset })
732  }
733  const pr = await askOne($, run, Q.pr())
734  if (typeof pr !== 'object') return stop(pr)
735  await saveAnswer($, { pr: is(pr.a, L.prYes), answeredAt: await nowMs($), offered: true })
736  if (setupRun !== run) return
737  setupRun = null
738  say($, '🧭 census-mod is set up', [
739    `recording: ${eff.record === 'yes' ? 'into census (the real store)' : eff.record === 'shadow' ? `shadow store ${eff.shadowDir ?? ''} — the dashboards and vitals do not read it; run /census-setup and answer Yes to record into census itself` : 'off'}`,
740    `band: ${drawOn ? `on, ${eff.placement === 'below' ? 'below the input' : 'above the input'}, ${PRESETS[preset ?? 'two']}` : 'off'}${rawEnv.CENSUS_STATUSLINE_SEGMENTS?.trim() ? ' (CENSUS_STATUSLINE_SEGMENTS overrides the layout)' : ''}`,
741    `PR segment (gh): ${saved.pr === false ? 'off, gh is never called' : 'on'}`,
742    ...(removed ? [`your status line was removed from settings.json; it is backed up in ${removed}`] : []),
743    ...(keptForShadow ? ['your status line was kept: CENSUS_MOD_STORE makes census-mod record to a shadow store, so the status line is still the real store\'s writer. Unset it and run /census-setup again to replace it'] : []),
744    'undo any time: /census-setup off (it restores a removed status line exactly), or /census-setup to answer again',
745    '📊 /census-mod:vitals shows this session on your phone',
746  ])
747}
748
749function startSetup($: EngineInterface, mode: 'full' | 'offer') {
750  const run = {}
751  setupRun = run
752  // After the command has replied: the dialogs follow it rather than holding it open.
753  $.clock.after(0, () => void setup($, run, mode).catch(() => { if (setupRun === run) setupRun = null }))
754}
755
756/** The first session after install: say so once, ever. An answer or a dismissal both count. */
757function offerOnce($: EngineInterface) {
758  if (saved.offered || setupRun || !interactive || offerTimer) return
759  const id = snap?.sessionId
760  offerTimer = $.clock.after(3000, () => {
761    offerTimer = null
762    // A quick exit (or a /clear) in between: there is no session left to ask in.
763    if (saved.offered || setupRun || !id || snap?.sessionId !== id) return
764    startSetup($, 'offer')
765  })
766}
767
768const DEFAULT_COLUMNS = 100
769
770/** The status line as runs, from the live snapshot: the same lines whichever site draws them. */
771async function statusLines($: EngineInterface): Promise<Line[]> {
772  if (!snap) return []
773  const now = (await nowMs($)) / 1000
774  const c = snap.counters
775  const expires = expiresAtMs(c, snap.ttl)
776  const input: RenderInput = {
777    now,
778    ctxPct: snap.ctxPct,
779    cache: { hitRatio: c.lastRatio, requests: c.requests, warm: isWarm(c, snap.ttl, now * 1000), expiresAt: expires === null ? null : expires / 1000, misses: 0 },
780    limits: rateLimitsOf(snap.rateLimits),
781    costUsd: snap.costUsd,
782    durationMs: snap.startedAt === null ? null : now * 1000 - snap.startedAt,
783    modelName: snap.model?.display_name ?? null,
784    git: snap.git,
785    pr: snap.pr,
786    cwd: snap.worktreePath ?? snap.cwd,
787    env,
788  }
789  return draw(input)
790}
791
792/** One <Box> row per line, a <Text> per coloured run. */
793function statusRows($: EngineInterface, e: Parameters<typeof $.ui.resolve>[0], lines: Line[]) {
794  const { Box, Text } = $.ui.resolve(e)
795  return lines.map((line, i) => (
796    <Box key={`census-${i}`}>
797      {line.map((run, j) => (
798        <Text key={`r${j}`} color={run.tone ? TONE_COLOR[run.tone] : undefined} bold={run.bold} wrap="truncate-end">
799          {run.t}
800        </Text>
801      ))}
802    </Box>
803  ))
804}
805
806/** Our work after `next`: whatever it throws must not cost the other mods their result. */
807async function quietly(work: () => Promise<void>): Promise<void> {
808  try {
809    await work()
810  } catch {
811    // recording is best-effort; the session carries on
812  }
813}
814
815export const register: Register = on => {
816  on('session.start', async ($, e, next) => {
817    const r = await next(e)
818    interactive = e.isInteractive
819    if (!interactive) return r
820    await quietly(async () => {
821      await $.command.register({ name: 'census-setup', description: 'Guided census-mod setup: record, draw, layout, gh. `off` stops it.', argumentHint: '[off]' }).catch(() => undefined)
822      rawEnv = await loadEnv($)
823      env = { ...rawEnv }
824      await bind($, await $.session.id(), e.cwd, null, 'session.start')
825    })
826    return r
827  })
828
829  // Every source: startup, resume, clear (a new session id), compact, fork.
830  on('classic.SessionStart', async ($, e, next) => {
831    const r = await next(e)
832    let watch: string[] = []
833    await quietly(async () => {
834      if (!(await isInteractive($))) return
835      interactive = true
836      if (Object.keys(rawEnv).length === 0) {
837        rawEnv = await loadEnv($)
838        env = { ...rawEnv }
839      }
840      // watchPaths are this hook's answer, so this one git call is awaited; bind reuses it.
841      const gitDir = await readGitDir($, e.cwd)
842      watch = watchPaths(gitDir)
843      if (e.source !== 'compact') {
844        const event: Event = e.source === 'startup' ? 'session.start' : (`session.${e.source}` as Event)
845        await bind($, e.session_id, e.cwd, e.transcript_path || null, event, gitDir)
846      } else if (snap && e.transcript_path) snap.transcriptPath = e.transcript_path
847    })
848    return watch.length ? { ...r, watchPaths: [...(r.watchPaths ?? []), ...watch] } : r
849  })
850
851  on('turn.complete', async ($, e, next) => {
852    const r = await next(e)
853    if (!interactive || !snap || e.agentId !== undefined || !e.usage) return r
854    const usage = e.usage
855    await quietly(async () => {
856      if (!snap) return
857      const now = await nowMs($)
858      lastActiveAt = now
859      snap.counters = addTurn(snap.counters, usage, now)
860      await saveCounters($)
861      // The reads below shell out: off the turn's path. They belong to THIS session's snapshot: a /clear
862      // that rebinds meanwhile must not get this transcript's ttl or title, nor a turn.complete write.
863      const mine = snap
864      $.clock.after(0, () => {
865        void (async () => {
866          await readTtl($, mine)
867          await readName($, mine)
868          await saveCounters($, mine)
869          if (snap !== mine) return
870          armCold($)
871          repaint($)
872          record($, 'turn.complete')
873        })().catch(() => undefined)
874      })
875    })
876    return r
877  })
878
879  on('session.measure', async ($, e, next) => {
880    const r = await next(e)
881    if (!interactive || !snap) return r
882    await quietly(async () => {
883      if (!snap) return
884      snap.ctxPct = e.context.percent ?? null
885      snap.ctxWindow = e.context.window ?? snap.ctxWindow
886      snap.costUsd = e.cost?.usd ?? snap.costUsd
887      const rates = e.rateLimits as RateLimit[]
888      const key = limitsFingerprint(rates)
889      if (key !== limitsKey) {
890        limitsKey = key
891        snap.rateLimits = rates
892        record($, 'rate-limit')
893      }
894      repaint($)
895    })
896    return r
897  })
898
899  on('classic.PostModelSwitch', async ($, e, next) => {
900    const r = await next(e)
901    if (!interactive || !snap) return r
902    await quietly(async () => {
903      if (!snap) return
904      if (e.to_model) snap.model = modelOf(e.to_model)
905      const label = (e as unknown as { cache_ttl?: unknown }).cache_ttl
906      if (label === '1h' || label === '5m') {
907        snap.ttl = label
908        snap.counters = withTtl(snap.counters, label)
909        armCold($)
910      }
911      repaint($)
912      record($, 'model')
913    })
914    return r
915  })
916
917  on('classic.CwdChanged', async ($, e, next) => {
918    const r = await next(e)
919    if (!interactive || !snap) return r
920    await quietly(async () => {
921      if (!snap) return
922      snap.cwd = e.new_cwd
923      snap.worktreePath = worktreeOf(await readGitDir($, e.new_cwd))
924      lastGitAt = null
925      scheduleGit($)
926      repaint($)
927      record($, 'cwd')
928    })
929    return r
930  })
931
932  // A compaction rewrites the prefix: the next request writes the cache afresh.
933  on('classic.PostCompact', async ($, e, next) => {
934    const r = await next(e)
935    if (!interactive || !snap) return r
936    await quietly(async () => {
937      if (!snap) return
938      snap.counters = compacted(snap.counters, await nowMs($))
939      await saveCounters($)
940      coldTimer?.cancel()
941      coldTimer = null
942      repaint($)
943      record($, 'compact')
944    })
945    return r
946  })
947
948  on('classic.FileChanged', async ($, e, next) => {
949    scheduleGit($)
950    return next(e)
951  })
952
953  // After the tool ran: its effect is what can have moved git or the PR.
954  on('tool.call', async ($, e, next) => {
955    const r = await next(e)
956    if (!interactive || !snap) return r
957    await quietly(async () => {
958      const call = e as unknown as { tool: string; command?: unknown }
959      lastActiveAt = await nowMs($)
960      if (touchesGit(call.tool)) scheduleGit($)
961      if (call.tool === 'Bash' && typeof call.command === 'string' && touchesPr(call.command)) scheduleGh($, 'push')
962    })
963    return r
964  })
965
966  // The ending session's last word: runs under the chain's shared budget, so it carries its own timeout.
967  on('command.run', { command: 'census-setup' }, async ($, e) => {
968    const args = ((e as unknown as { args?: string }).args ?? '').trim()
969    if (args === 'off') {
970      setupRun = null
971      await quietly(async () => { if (Object.keys(rawEnv).length === 0) { rawEnv = await loadEnv($); env = { ...rawEnv } } await loadSetup($); await turnOff($) })
972      return { text: 'census-mod is off. /census-setup to turn it on again.' }
973    }
974    if (args !== '') return { text: 'Usage: /census-setup (guided setup) or /census-setup off' }
975    if (Object.keys(rawEnv).length === 0) { rawEnv = await loadEnv($); env = { ...rawEnv } }
976    startSetup($, 'full')
977    return { text: '🧭 census-setup: a few questions follow.' }
978  })
979
980  on('session.end', async ($, e, next) => {
981    await quietly(async () => {
982      if (interactive && snap && snap.sessionId === e.sessionId) {
983        cancelTimers()
984        pending = null
985        const timeoutMs = endTimeoutMs(next.budget.remainingMs)
986        if (timeoutMs !== null && !ended.has(e.sessionId)) {
987          ended.add(e.sessionId)
988          await ingest($, 'session.end', e.reason, timeoutMs)
989        }
990      }
991    })
992    return next(e)
993  })
994
995  // Above the input: the band. Ours first, whatever other mods draw beneath it after.
996  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
997    const inner = await next(e)
998    if (e.props.hasSurvey || !interactive || !snap || !eff.draw || eff.placement !== 'above') return inner
999    const lines = (await statusLines($)).slice(0, Math.max(0, e.props.maxRows)).map(l => fit(l, e.props.bodyColumns))
1000    if (lines.length === 0) return inner
1001    const { Box } = $.ui.resolve(e)
1002    return (
1003      <Box flexDirection="column">
1004        {statusRows($, e, lines)}
1005        {inner}
1006      </Box>
1007    )
1008  })
1009
1010  // Below the input: under Claude Code's own hint line. The engine always draws its permission pill and hint
1011  // first and a tree cannot go above them, so the engine's line (`inner`) leads and our rows follow on their own
1012  // lines; a tree without `inner` would put row one on the pill's line. Drawn while typing and while working too.
1013  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
1014    const inner = await next(e)
1015    if (!interactive || !snap || !eff.draw || eff.placement !== 'below') return inner
1016    const columns = e.viewport?.columns ?? DEFAULT_COLUMNS
1017    const lines = (await statusLines($)).map(l => fit(l, columns))
1018    if (lines.length === 0) return inner
1019    const { Box } = $.ui.resolve(e)
1020    return (
1021      <Box flexDirection="column">
1022        {inner}
1023        {statusRows($, e, lines)}
1024      </Box>
1025    )
1026  })
1027}
1028
plugin/core/cache.ts 93 lines
1import type { Counters, Ttl } from './types'
2
3// The prompt-cache lifetime the session's main conversation writes. Read once per turn off the
4// transcript tail; ported from context-vigil-mod's core/cache-ttl.ts (same TAIL_CMD, same rule:
5// any 5m write makes it 5m). Unknown is treated as 5m, the shorter and so the safer claim.
6export type Writes = { h1: number; m5: number }
7export const TAIL_CMD = 'tail -c 65536 "$1" | grep -o \'"cache_creation":{[^}]*}\''
8export const DEFAULT_TTL: Ttl = '5m'
9
10const field = (line: string, key: string): number => {
11  const m = new RegExp(`"ephemeral_${key}_input_tokens":(\\d+)`).exec(line)
12  return m ? Number.parseInt(m[1] ?? '0', 10) : 0
13}
14
15export function parseWrites(stdout: string): Writes | null {
16  const lines = stdout.split('\n')
17  for (let i = lines.length - 1; i >= 0; i--) {
18    const line = lines[i] ?? ''
19    if (!line.includes('"cache_creation"')) continue
20    const w = { h1: field(line, '1h'), m5: field(line, '5m') }
21    if (w.h1 > 0 || w.m5 > 0) return w
22  }
23  return null
24}
25
26/** null = nothing found in the tail. */
27export function ttlFromWrites(w: Writes | null): Ttl | null {
28  if (w === null) return null
29  return w.m5 > 0 ? '5m' : '1h'
30}
31
32export const ttlMs = (ttl: Ttl): number => (ttl === '1h' ? 3_600_000 : 300_000)
33
34export const EMPTY_COUNTERS: Counters = {
35  requests: 0, input: 0, read: 0, create: 0, output: 0,
36  lastTurnAt: null, ttl: null, lastRatio: null, cold: false, updatedAt: 0,
37}
38
39export type TurnTokens = {
40  input_tokens: number
41  output_tokens: number
42  cache_read_input_tokens: number
43  cache_creation_input_tokens: number
44}
45
46/** read / (read + creation + uncached input); null when the turn read no input at all. */
47export function ratio(read: number, create: number, input: number): number | null {
48  const total = read + create + input
49  return total > 0 ? read / total : null
50}
51
52/**
53 * Fold one main-thread turn in. `TurnUsage` is the turn's real requests SUMMED (typings), so a
54 * turn that ran several tool steps reports one ratio over all of them, and `requests` counts
55 * turns, not API calls.
56 */
57export function addTurn(c: Counters, u: TurnTokens, now: number): Counters {
58  return {
59    requests: c.requests + 1,
60    input: c.input + u.input_tokens,
61    read: c.read + u.cache_read_input_tokens,
62    create: c.create + u.cache_creation_input_tokens,
63    output: c.output + u.output_tokens,
64    lastTurnAt: now,
65    ttl: c.ttl,
66    lastRatio: ratio(u.cache_read_input_tokens, u.cache_creation_input_tokens, u.input_tokens),
67    cold: false,
68    updatedAt: now,
69  }
70}
71
72export const sessionRatio = (c: Counters): number | null => ratio(c.read, c.create, c.input)
73export const totalInputTokens = (c: Counters): number => c.input + c.read + c.create
74
75/** Epoch ms the cache goes cold, or null before any counted turn. */
76export function expiresAtMs(c: Counters, ttl: Ttl): number | null {
77  return c.lastTurnAt === null ? null : c.lastTurnAt + ttlMs(ttl)
78}
79
80export function isWarm(c: Counters, ttl: Ttl, now: number): boolean {
81  const at = expiresAtMs(c, ttl)
82  return !c.cold && at !== null && now < at
83}
84
85/** A compaction rewrites the prefix: the next request writes the cache afresh. */
86export const compacted = (c: Counters, now: number): Counters => ({ ...c, cold: true, updatedAt: now })
87
88export const withTtl = (c: Counters, ttl: Ttl): Counters => ({ ...c, ttl })
89
90export const STORE_PREFIX = 'counters:'
91export const counterKey = (sessionId: string): string => `${STORE_PREFIX}${sessionId}`
92export const COUNTER_KEEP_MS = 7 * 24 * 3_600_000
93
plugin/core/census.ts 66 lines
1import { configRootOf, expandHome, joinPath, trimSeps } from './home'
2import type { HomeEnv } from './home'
3
4export type CensusEnv = HomeEnv & {
5  CENSUS_MOD_STORE?: string
6  CENSUS_STORE?: string
7}
8
9/** Census reads a store ending `.json` as that FILE: its census dir is the parent (store.census_dir). */
10const asDir = (p: string): string => (p.endsWith('.json') ? trimSeps(p.slice(0, Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))) || '/') : p)
11
12/** Shadow mode: the store value as given (`~` expanded), which census itself reads, `.json` and all. */
13export function shadowStore(env: CensusEnv): string | null {
14  return env.CENSUS_MOD_STORE ? trimSeps(expandHome(env.CENSUS_MOD_STORE, env)) : null
15}
16
17/** Shadow mode: the DIR that store lives in, where `cli.path` sits. */
18export function shadowDir(env: CensusEnv): string | null {
19  const store = shadowStore(env)
20  return store ? asDir(store) : null
21}
22
23/** The census dir readers use: `CENSUS_STORE`, else `<config dir>/census`. */
24export function censusDir(env: CensusEnv): string | null {
25  if (env.CENSUS_STORE) return asDir(trimSeps(expandHome(env.CENSUS_STORE, env)))
26  const root = configRootOf(env)
27  return root ? joinPath(root, 'census') : null
28}
29
30/**
31 * census-mod records through its OWN bundled copy of census's ingest (`<plugin root>/scripts/cli.py`), never through a
32 * census plugin: a Python script run under python3 with a list argv.
33 */
34export const bundledCli = (pluginRoot: string): string => joinPath(pluginRoot, 'scripts', 'cli.py')
35
36/** The ingest argv for a launcher that passed `--version`. */
37export const ingestArgv = (cli: string, python: readonly string[] = ['python3']): string[] => [...python, cli, 'ingest']
38
39/** Shadow mode points the child's census at the shadow dir; ingest honours `CENSUS_STORE`. */
40export function ingestEnv(env: CensusEnv): Record<string, string> | undefined {
41  // Unchanged, `.json` included: census reads a file-valued store itself; only our pointer lookup wants the parent.
42  const store = shadowStore(env)
43  return store ? { CENSUS_STORE: store } : undefined
44}
45
46/** Time budgets (ms) */
47export const INGEST_TIMEOUT_MS = 10_000
48export const COALESCE_MS = 2000
49export const END_RESERVE_MS = 200
50export const END_MIN_MS = 300
51
52/**
53 * `timeoutMs` for the final ingest so it finishes inside `session.end`'s shared budget, or null to
54 * skip it: with under END_MIN_MS left an ingest would only overrun the exit.
55 */
56export function endTimeoutMs(remainingMs: number): number | null {
57  if (!Number.isFinite(remainingMs)) return 1000
58  if (remainingMs < END_MIN_MS) return null
59  return Math.min(1000, Math.floor(remainingMs) - END_RESERVE_MS)
60}
61
62/** How long to wait before the next ingest may run: 0 when the last was over `COALESCE_MS` ago. */
63export function delayFor(now: number, lastAt: number | null): number {
64  return lastAt === null ? 0 : Math.max(0, lastAt + COALESCE_MS - now)
65}
66
plugin/core/gh.ts 55 lines
1import type { Pr } from './types'
2
3export const GH_TIMEOUT_MS = 5000
4export const BACKOFF_MS = 10 * 60_000
5export const MAX_AGE_MS = 10 * 60_000
6export const ACTIVE_MS = 10 * 60_000
7
8/** Exactly: gh pr list --head <branch> --state open --limit 1 --json number,url,reviewDecision */
9export const ghArgv = (branch: string): string[] =>
10  ['gh', 'pr', 'list', '--head', branch, '--state', 'open', '--limit', '1', '--json', 'number,url,reviewDecision']
11
12/** What is kept per repo+branch. `pr: null` = checked, no open PR. */
13export type GhEntry = { at: number; pr: Pr | null; failedAt?: number }
14
15const REVIEW: Record<string, string> = {
16  APPROVED: 'approved',
17  REVIEW_REQUIRED: 'pending',
18  CHANGES_REQUESTED: 'changes_requested',
19}
20
21/** `gh pr list` JSON to a PR; empty list = no open PR; undefined = not parseable. */
22export function parsePrList(stdout: string): Pr | null | undefined {
23  let data: unknown
24  try {
25    data = JSON.parse(stdout)
26  } catch {
27    return undefined
28  }
29  if (!Array.isArray(data)) return undefined
30  const first = data[0] as { number?: unknown; url?: unknown; reviewDecision?: unknown } | undefined
31  if (first === undefined) return null
32  if (typeof first.number !== 'number') return undefined
33  const pr: Pr = { number: first.number, url: typeof first.url === 'string' ? first.url : '' }
34  const state = typeof first.reviewDecision === 'string' ? REVIEW[first.reviewDecision] : undefined
35  if (state) pr.reviewState = state
36  return pr
37}
38
39/** `start`: the first sight of a branch by a bound session (a start, a /clear, a restart): a fresh cached answer stands. */
40export type Why = 'branch' | 'push' | 'age' | 'start'
41
42/** A Bash command that can have opened, updated or closed a PR. */
43export const touchesPr = (command: string): boolean => command.includes('git push') || command.includes('gh pr')
44
45export function shouldRefresh(why: Why, entry: GhEntry | undefined, now: number, lastActiveAt: number | null): boolean {
46  if (entry?.failedAt !== undefined && now < entry.failedAt + BACKOFF_MS) return false
47  if (entry === undefined) return true
48  if (why === 'branch' || why === 'push') return true
49  if (why === 'start') return now - entry.at > MAX_AGE_MS
50  return now - entry.at > MAX_AGE_MS && lastActiveAt !== null && now - lastActiveAt < ACTIVE_MS
51}
52
53/** JSON-encoded pair: any root/branch (a `|` in either included) maps to its own key. */
54export const ghKey = (root: string, branch: string): string => `gh:${JSON.stringify([root, branch])}`
55
plugin/core/git.ts 52 lines
1import { isAbsolute } from './home'
2import type { GitState } from './types'
3
4// Exactly one status call (no untracked scan: the changes segment never counted untracked files).
5export const GIT_STATUS_ARGV = ['git', '--no-optional-locks', 'status', '--porcelain=2', '--branch', '-uno'] as const
6// Line 1: the git dir (HEAD and index live there, elsewhere for a linked worktree); line 2: the top level.
7export const GIT_DIR_ARGV = ['git', 'rev-parse', '--absolute-git-dir', '--show-toplevel'] as const
8export const COALESCE_MS = 5000
9
10export type RunOut = { exitCode: number; stdout: string }
11
12/**
13 * Port of census's gitcache.parse_status. Detached HEAD reports the short oid; an unborn branch
14 * (oid `(initial)`) has no branch to report; untracked (`?`) and ignored (`!`) never count.
15 */
16export function parseStatus(stdout: string): GitState {
17  let head: string | null = null
18  let oid: string | null = null
19  let upstream: string | null = null
20  let ahead = 0
21  let uncommitted = 0
22  for (const line of stdout.split('\n')) {
23    if (line.startsWith('# branch.head ')) head = line.slice('# branch.head '.length).trim()
24    else if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length).trim()
25    else if (line.startsWith('# branch.upstream ')) upstream = line.slice('# branch.upstream '.length).trim()
26    else if (line.startsWith('# branch.ab ')) {
27      for (const part of line.split(/\s+/).slice(2)) if (/^\+\d+$/.test(part)) ahead = Number.parseInt(part.slice(1), 10)
28    } else if (['1 ', '2 ', 'u '].includes(line.slice(0, 2))) uncommitted++
29  }
30  const detached = head === '(detached)'
31  const unborn = oid === '(initial)'
32  const branch = detached ? (oid && !unborn ? oid.slice(0, 7) : null) : unborn ? null : head || null
33  return { branch, detached, uncommitted, ahead: upstream ? ahead : 0, hasUpstream: upstream !== null }
34}
35
36/** What to watch for a `rev-parse --absolute-git-dir --show-toplevel` answer; nothing when it failed. */
37export function watchPaths(out: RunOut): string[] {
38  const dir = out.exitCode === 0 ? (out.stdout.split('\n')[0] ?? '').trim() : ''
39  // git prints forward slashes even on Windows (`C:/repo/.git`)
40  return isAbsolute(dir) ? [`${dir}/HEAD`, `${dir}/index`] : []
41}
42
43/** The linked worktree's top level, or null in the main checkout (git dir is `<repo>/.git`). */
44export function worktreeOf(out: RunOut): string | null {
45  if (out.exitCode !== 0) return null
46  const [dir = '', top = ''] = out.stdout.split('\n').map(l => l.trim())
47  return /[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/.test(dir) && isAbsolute(top) ? top : null
48}
49
50const TOUCH = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
51export const touchesGit = (tool: string): boolean => TOUCH.has(tool)
52
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/name.ts 20 lines
1import { configRootOf, joinPath, trimSeps } from './home'
2import type { HomeEnv } from './home'
3
4export const NAME = 'census-mod'
5export const SCHEMA = 1
6
7/** `/a/b//` -> `/a/b`; `C:\x\` -> `C:\x`; a root stays a root (a bare trim would leave the empty string). */
8export const trimSlashes = trimSeps
9
10/** The config dir: CLAUDE_CONFIG_DIR, else <home>/.claude (home: HOME, USERPROFILE, HOMEDRIVE+HOMEPATH); null if none can be told. */
11export function configRoot(env: HomeEnv): string | null {
12  return configRootOf(env)
13}
14
15// Where Claude Code keeps a session's transcript, for when no hook has carried the path yet (a hot
16// reload fires session.start but not classic.SessionStart). Ported from context-vigil-mod.
17export function transcriptPathFor(root: string, cwd: string, sessionId: string): string {
18  return joinPath(root, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'), `${sessionId}.jsonl`)
19}
20
plugin/core/portable.ts 56 lines
1// What the hooks file runs outside the engine, per platform. Pure: no `$`. A path says which platform it is on
2// (`C:\x` and `\\server\x` are Windows, `/x` is POSIX), so nothing here sniffs the OS.
3import { isAbsolute } from './home'
4
5export const onWindows = (path: string): boolean => isAbsolute(path) && !path.startsWith('/')
6
7/** Python launchers to try, in order: `python3` (macOS, Linux), `python`, then the Windows launcher `py -3`. */
8export const PYTHON_CANDIDATES: readonly (readonly string[])[] = [['python3'], ['python'], ['py', '-3']]
9
10/** The first launcher whose `--version` ran, given a probe that says whether one did; null when none. */
11export async function pickPython(works: (argv: string[]) => Promise<boolean>): Promise<string[] | null> {
12  for (const candidate of PYTHON_CANDIDATES) {
13    const argv = [...candidate, '--version']
14    if (await works(argv).catch(() => false)) return [...candidate]
15  }
16  return null
17}
18
19// Windows has no argv-only move/delete: `cmd /c` would parse the paths as a command line. So there, nothing is spawned:
20// the caller writes the file in place and empties a backup instead of deleting it (see the hooks file).
21
22/** Rename `from` over `to` with `mv -f`; null on Windows. */
23export const moveArgv = (from: string, to: string): string[] | null => (onWindows(to) ? null : ['mv', '-f', from, to])
24
25/** Remove a file, quietly when it is not there, with `rm -f`; null on Windows. */
26export const removeArgv = (path: string): string[] | null => (onWindows(path) ? null : ['rm', '-f', path])
27
28/** Copy keeping the mode (POSIX only: a Windows file has no mode to keep, so there is nothing to run). */
29export const copyModeArgv = (from: string, to: string): string[] | null => (onWindows(to) ? null : ['cp', '-p', from, to])
30
31/** 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. */
32export function tailBytes(text: string, bytes: number): string {
33  let used = 0
34  let i = text.length
35  while (i > 0) {
36    const code = text.charCodeAt(i - 1)
37    const isLow = code >= 0xdc00 && code <= 0xdfff && i > 1
38    const size = isLow ? 4 : code < 0x80 ? 1 : code < 0x800 ? 2 : 3
39    if (used + size > bytes) break
40    used += size
41    i -= isLow ? 2 : 1
42  }
43
44  return text.slice(i)
45}
46
47/** What `tail -c N file | grep -o '"cache_creation":{[^}]*}'` prints, from the file's text (for where there is no sh). */
48export function cacheLinesFromText(text: string, bytes = 65536): string {
49  return (tailBytes(text, bytes).match(/"cache_creation":\{[^}]*\}/g) ?? []).join('\n')
50}
51
52/** What `grep -F '"type":"custom-title"'` prints (the last `bytes` of the file only when given). */
53export function titleLinesFromText(text: string, bytes?: number): string {
54  return (bytes ? tailBytes(text, bytes) : text).split('\n').filter(l => l.includes('"type":"custom-title"')).join('\n')
55}
56
plugin/core/payload.ts 79 lines
1import { SCHEMA } from './name'
2import { expiresAtMs, isWarm, sessionRatio, totalInputTokens } from './cache'
3import type { RateLimit, Snap } from './types'
4
5export type Event =
6  | 'session.start' | 'session.clear' | 'session.resume' | 'session.fork' | 'turn.complete'
7  | 'rate-limit' | 'model' | 'cwd' | 'branch' | 'pr' | 'cache.cold' | 'compact' | 'session.end'
8
9/** `claude-opus-5-5[1m]` -> `{ id: 'claude-opus-5-5', display_name: 'Opus 5.5' }`. */
10export function modelOf(raw: string): { id: string; display_name: string } {
11  const id = raw.replace(/\[[^\]]*\]$/, '')
12  const m = /^claude-(opus|sonnet|haiku|fable)-(\d+)-(\d+)/.exec(id)
13  const name = m ? `${(m[1] ?? '').charAt(0).toUpperCase()}${(m[1] ?? '').slice(1)} ${m[2]}.${m[3]}` : id
14  return { id, display_name: name }
15}
16
17/** ISO `resetsAt` and decimal `percentUsed` to census's windows: epoch SECONDS, or census drops it. */
18export function rateLimitsOf(limits: RateLimit[]): Record<string, { used_percentage: number; resets_at: number }> {
19  const out: Record<string, { used_percentage: number; resets_at: number }> = {}
20  for (const l of limits) {
21    const ms = l.resetsAt ? Date.parse(l.resetsAt) : Number.NaN
22    if (!Number.isFinite(ms) || !Number.isFinite(l.percentUsed)) continue
23    out[l.kind] = { used_percentage: l.percentUsed, resets_at: Math.floor(ms / 1000) }
24  }
25  return out
26}
27
28/**
29 * The status-line payload shape census reads, plus `census_mod`. `now` in ms. Keys census does not
30 * know are stored verbatim, so every extra here is free; a key this cannot fill is OMITTED, not
31 * faked (census keeps the prior value of a null, and drops a half-formed window).
32 */
33export function buildPayload(s: Snap, now: number, event: Event, ended?: string): Record<string, unknown> {
34  const c = s.counters
35  const expires = expiresAtMs(c, s.ttl)
36  const cache: Record<string, unknown> = {
37    warm: isWarm(c, s.ttl, now),
38    ttl: s.ttl,
39    requests: c.requests,
40  }
41  if (expires !== null) cache.expires_at = Math.floor(expires / 1000)
42  if (c.lastRatio !== null) cache.hit_ratio = c.lastRatio
43  const whole = sessionRatio(c)
44  if (whole !== null) cache.session_hit_ratio = whole
45
46  const p: Record<string, unknown> = { session_id: s.sessionId }
47  if (s.transcriptPath) p.transcript_path = s.transcriptPath
48  p.cwd = s.cwd
49  // No project_dir: the mod tracks the current directory, not a project root, and would go stale after a cd.
50  p.workspace = { current_dir: s.cwd }
51  if (s.worktreePath) p.worktree = { path: s.worktreePath }
52  if (s.model) p.model = s.model
53  p.context_window = {
54    used_percentage: s.ctxPct,
55    ...(s.ctxWindow !== null ? { context_window_size: s.ctxWindow } : {}),
56    total_input_tokens: totalInputTokens(c),
57    total_output_tokens: c.output,
58  }
59  const cost: Record<string, number> = {}
60  if (s.costUsd !== null) cost.total_cost_usd = s.costUsd
61  if (s.startedAt !== null) cost.total_duration_ms = Math.max(0, Math.round(now - s.startedAt))
62  p.cost = cost
63  const limits = rateLimitsOf(s.rateLimits)
64  if (Object.keys(limits).length) p.rate_limits = limits
65  p.prompt_cache = cache
66  if (s.sessionName) p.session_name = s.sessionName
67  if (s.pr) p.pr = { number: s.pr.number, url: s.pr.url, ...(s.pr.reviewState ? { review_state: s.pr.reviewState } : {}) }
68  if (s.version) p.version = s.version
69  p.census_mod = {
70    version: SCHEMA,
71    ...(s.proc ? { pid: s.proc.pid, proc_start: s.proc.procStart } : {}),
72    event,
73    ...(ended !== undefined ? { ended } : {}),
74    // The `-uno` git pass, in gitcache's field names, so readers never shell out to git themselves.
75    git: s.git ? { branch: s.git.branch, uncommitted: s.git.uncommitted, ahead: s.git.ahead, has_upstream: s.git.hasUpstream, detached: s.git.detached } : null,
76  }
77  return p
78}
79
plugin/core/registry.ts 42 lines
1import type { Proc } from './types'
2
3/**
4 * Claude Code's own session registry holds `<config dir>/sessions/<pid>.json` per live process,
5 * with the `procStart` string (ps lstart text, in another zone: only ever compared as a string)
6 * and the version. The entry whose `sessionId` is ours names our process.
7 */
8export function findProc(files: { text: string }[], sessionId: string): Proc | null {
9  for (const f of files) {
10    let d: unknown
11    try {
12      d = JSON.parse(f.text)
13    } catch {
14      continue
15    }
16    if (!d || typeof d !== 'object' || Array.isArray(d)) continue // null, a scalar or an array is no registry entry
17    const r = d as { pid?: unknown; sessionId?: unknown; procStart?: unknown; version?: unknown }
18    if (r.sessionId !== sessionId || typeof r.pid !== 'number' || r.pid <= 1 || typeof r.procStart !== 'string') continue
19    return { pid: r.pid, procStart: r.procStart, ...(typeof r.version === 'string' ? { version: r.version } : {}) }
20  }
21  return null
22}
23
24/** The last `custom-title` row of `grep -F '"type":"custom-title"'` output. */
25export function lastTitle(stdout: string): string | null {
26  const lines = stdout.split('\n').filter(l => l.includes('"custom-title"'))
27  for (let i = lines.length - 1; i >= 0; i--) {
28    try {
29      const t = (JSON.parse(lines[i] ?? '') as { customTitle?: unknown }).customTitle
30      if (typeof t === 'string' && t.trim()) return t.trim()
31    } catch {
32      // a row cut mid-write; try the one before
33    }
34  }
35  return null
36}
37
38/** The whole transcript, once per bound session. */
39export const TITLE_ARGV = (path: string): string[] => ['grep', '-h', '-F', '"type":"custom-title"', path]
40/** After each turn: only the tail, where a later /rename lands, so a long session is not re-read whole every turn. */
41export const TITLE_TAIL_CMD = 'tail -c 262144 "$1" | grep -F \'"type":"custom-title"\''
42
plugin/core/render.ts 231 lines
1// The status line as data: a port of census's render.py (segments, glyphs, thresholds, the `/` line
2// syntax). A line is a list of runs; the shell turns runs into <Text>, tests read them as text.
3import type { GitState, Pr } from './types'
4
5export type Tone = 'grey' | 'cyan' | 'green' | 'yellow' | 'magenta' | 'pac' | 'white' | 'red' | 'orange' | 'claude'
6export type Run = { t: string; tone?: Tone; bold?: boolean }
7export type Line = Run[]
8
9/** render.py's ANSI palette as Text colours (names the surface's chalk-style colours accept; orange is 256-colour 208). */
10export const TONE_COLOR: Record<Tone, string> = {
11  grey: 'gray', cyan: 'cyan', green: 'green', yellow: 'yellow', magenta: 'magenta',
12  pac: 'yellowBright', white: 'whiteBright', red: 'red', orange: '#ff8700',
13  claude: '#D97757', // the asterisk's own orange (proven live as a raw hex Text colour)
14}
15
16export type RenderEnv = {
17  CENSUS_STATUSLINE_SEGMENTS?: string
18  CENSUS_STATUSLINE_MASCOT?: string
19  CLAUDE_COST_BUDGET?: string
20  CLAUDE_PROFILE?: string
21  CLAUDE_CONFIG_DIR?: string
22}
23
24export type RenderInput = {
25  now: number // epoch seconds
26  ctxPct: number | null
27  cache: { hitRatio: number | null; requests: number; warm: boolean; expiresAt: number | null; misses: number }
28  limits: Record<string, { used_percentage: number; resets_at: number } | undefined>
29  costUsd: number | null
30  durationMs: number | null
31  modelName: string | null
32  git: Pick<GitState, 'branch' | 'uncommitted' | 'ahead' | 'hasUpstream'> | null
33  pr: Pr | null
34  cwd: string
35  env: RenderEnv
36}
37
38export const DEFAULT_SEGMENTS = 'context,cache,limits,cost/model,git,dir,changes,pr'
39export const DEFAULT_BUDGET = 20
40export const BAR_WIDTH = 10
41
42/** printf "%.0f": round half to even. */
43export function roundHalfEven(x: number): number {
44  const f = Math.floor(x)
45  const d = x - f
46  if (d < 0.5) return f
47  if (d > 0.5) return f + 1
48  return f % 2 === 0 ? f : f + 1
49}
50
51export const levelTone = (pct: number): Tone => (pct >= 90 ? 'red' : pct >= 75 ? 'orange' : 'green')
52export const levelToneInv = (pct: number): Tone => (pct >= 90 ? 'green' : pct >= 75 ? 'orange' : 'red')
53
54export function pacBar(pct: number, width = BAR_WIDTH, glyph = '•', ahead?: string): Run[] {
55  const track = ahead ?? glyph
56  const filled = Math.max(0, Math.min(Math.floor((pct / 100) * width + 0.5), width))
57  const pac = Math.min(filled, width - 1)
58  const out: Run[] = []
59  for (let j = 0; j < width; j++) {
60    if (j < pac) out.push({ t: glyph, tone: levelTone(Math.floor(((j + 1) * 100) / width)) })
61    else if (j === pac) out.push({ t: 'ᗧ', tone: 'pac' })
62    else out.push({ t: track, tone: 'white' })
63  }
64  return out
65}
66
67export function fmtReset(target: number, now: number): string {
68  const diff = Math.max(0, Math.floor(target - now))
69  if (diff >= 86400) return `${Math.floor(diff / 86400)}d${Math.floor((diff % 86400) / 3600)}h`
70  if (diff >= 3600) return `${Math.floor(diff / 3600)}h${Math.floor((diff % 3600) / 60)}m`
71  return `${Math.floor(diff / 60)}m`
72}
73
74const sp: Run = { t: ' ' }
75const grey = (t: string): Run => ({ t, tone: 'grey' })
76
77function segContext(i: RenderInput): Line[] {
78  if (i.ctxPct === null) {
79    return [[grey('🧠'), sp, { t: 'ᗧ', tone: 'pac' }, { t: '•'.repeat(BAR_WIDTH - 1), tone: 'white' }, sp, grey('--%')]]
80  }
81  const shown = roundHalfEven(i.ctxPct)
82  return [[grey('🧠'), sp, ...pacBar(i.ctxPct), sp, { t: `${shown}%`, tone: levelTone(shown) }]]
83}
84
85function segCache(i: RenderInput): Line[] {
86  const { hitRatio, requests, warm, expiresAt, misses } = i.cache
87  if (hitRatio === null || !(requests > 0)) return []
88  const pct = roundHalfEven(Math.max(0, Math.min(100, hitRatio * 100)))
89  const line: Line = [grey(warm ? '🎯' : '🧊'), sp, { t: `${pct}%`, tone: levelToneInv(pct) }]
90  if (warm && expiresAt !== null) line.push(sp, grey(`⟳ ${fmtReset(expiresAt, i.now)}`))
91  if (misses > 0) line.push(sp, { t: `✗${misses}`, tone: 'red' })
92  return [line]
93}
94
95function usageBar(i: RenderInput, w: { used_percentage: number; resets_at: number }): Line {
96  const shown = roundHalfEven(w.used_percentage)
97  return [...pacBar(w.used_percentage), sp, { t: `${shown}%`, tone: levelTone(shown) }, sp, grey(`⟳ ${fmtReset(w.resets_at, i.now)}`)]
98}
99
100function segLimits(i: RenderInput): Line[] {
101  const out: Line[] = []
102  for (const [key, emoji] of [['five_hour', '⏳'], ['seven_day', '📅']] as const) {
103    const w = i.limits[key]
104    if (!w) continue
105    if (w.resets_at <= i.now) continue // an expired window is a fossil
106    out.push([grey(emoji), sp, ...usageBar(i, w)])
107  }
108  return out
109}
110
111function budget(env: RenderEnv): number {
112  // The whole value or nothing: "5oops" is not a $5 budget.
113  const t = (env.CLAUDE_COST_BUDGET ?? '').trim()
114  const v = /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : Number.NaN
115  return Number.isFinite(v) ? v : DEFAULT_BUDGET
116}
117
118function segCost(i: RenderInput): Line[] {
119  if (i.costUsd === null) return []
120  const b = budget(i.env)
121  const pct = b <= 0 ? 0 : Math.max(0, roundHalfEven((i.costUsd / b) * 100))
122  const out: Line[] = [[grey('💸'), sp, ...pacBar(Math.min(pct, 100), BAR_WIDTH, '•', '$'), sp, { t: `$${i.costUsd.toFixed(2)}`, tone: levelTone(pct) }]]
123  if (i.durationMs !== null && i.durationMs > 0) {
124    const burn = i.costUsd / (i.durationMs / 3_600_000)
125    const bi = roundHalfEven(burn)
126    const [tone, emoji]: [Tone, string] = bi >= 20 ? ['red', '🚀'] : bi >= 8 ? ['orange', '🔥'] : ['green', '🐌']
127    out.push([{ t: `${emoji} ` }, { t: `$${bi >= 100 ? String(bi) : burn.toFixed(2)}/hr`, tone }])
128  }
129  return out
130}
131
132export function detectAccount(env: RenderEnv): 'personal' | 'work' {
133  if (env.CLAUDE_PROFILE) return ['personal', 'home', 'p'].includes(env.CLAUDE_PROFILE) ? 'personal' : 'work'
134  if (env.CLAUDE_CONFIG_DIR) return env.CLAUDE_CONFIG_DIR.includes('personal') ? 'personal' : 'work'
135  return 'work'
136}
137
138export const DEFAULT_MASCOT = '✻'
139export const mascot = (env: RenderEnv): string => env.CENSUS_STATUSLINE_MASCOT || DEFAULT_MASCOT
140
141function segModel(i: RenderInput): Line[] {
142  const custom = i.env.CENSUS_STATUSLINE_MASCOT
143  // The default is a coloured run of its own; an override is drawn as given.
144  const glyph: Run = custom ? { t: `${custom} ` } : { t: `${DEFAULT_MASCOT} `, tone: 'claude', bold: true } // bold, and the glyph plus one space: a two-column slot like an emoji
145  return [[glyph, ...(custom ? [] : [sp]), { t: i.modelName || 'Claude', tone: 'cyan' }]]
146}
147
148function segGit(i: RenderInput): Line[] {
149  if (!i.git?.branch) return []
150  return [[{ t: `🌿 ${i.git.branch}`, tone: 'green' }]]
151}
152
153function segDir(i: RenderInput): Line[] {
154  const parts = i.cwd.split(/[\\/]/)
155  const shown = parts.length <= 3 ? i.cwd : `…${parts.slice(-3).map(p => `/${p}`).join('')}`
156  return [[{ t: `📁 ${shown}`, tone: 'magenta' }]]
157}
158
159function segChanges(i: RenderInput): Line[] {
160  if (!i.git?.branch) return []
161  const line: Line = [{ t: `✏️ ${i.git.uncommitted}`, tone: 'yellow' }]
162  if (i.git.ahead > 0 && i.git.hasUpstream) line.push({ t: '  ' }, { t: `⬆️ ${i.git.ahead}`, tone: 'yellow' })
163  return [line]
164}
165
166const REVIEW_TONE: Record<string, Tone> = { approved: 'green', pending: 'yellow', changes_requested: 'red' }
167
168/** The branch's open PR (from the mod's gh cache): `🔀 #12 approved`, the state coloured; hidden without one. */
169function segPr(i: RenderInput): Line[] {
170  if (!i.pr || !(i.pr.number > 0)) return []
171  const line: Line = [{ t: `🔀 #${i.pr.number}` }]
172  if (i.pr.reviewState) line.push(sp, { t: i.pr.reviewState, tone: REVIEW_TONE[i.pr.reviewState] ?? 'grey' })
173  return [line]
174}
175
176export const SEGMENTS: Record<string, (i: RenderInput) => Line[]> = {
177  context: segContext, cache: segCache, limits: segLimits, cost: segCost, model: segModel, git: segGit, dir: segDir, changes: segChanges, pr: segPr,
178}
179
180/** The configured segments as lines of names; `/` starts a new line, unknown names are dropped. */
181export function layout(spec: string | undefined): string[][] {
182  const text = spec?.trim() || DEFAULT_SEGMENTS
183  return text.split('/').map(line => line.split(',').map(s => s.trim()).filter(n => Object.hasOwn(SEGMENTS, n)))
184}
185
186const SEPARATOR: Run = { t: ' │ ', tone: 'grey' }
187
188/** Each configured line as runs, parts joined by the grey bar; an empty line is dropped. */
189export function draw(i: RenderInput): Line[] {
190  const lines: Line[] = []
191  for (const names of layout(i.env.CENSUS_STATUSLINE_SEGMENTS)) {
192    const parts = names.flatMap(n => (Object.hasOwn(SEGMENTS, n) ? SEGMENTS[n]?.(i) : undefined) ?? [])
193    if (parts.length) lines.push(parts.flatMap((p, k) => (k === 0 ? p : [SEPARATOR, ...p])))
194  }
195  return lines
196}
197
198export const plain = (line: Line): string => line.map(r => r.t).join('')
199
200/** Terminal columns a string takes: wide emoji and CJK count 2, a variation selector widens its base. */
201export function displayWidth(text: string): number {
202  let w = 0
203  let joined = false // after a zero-width joiner the next emoji is part of the same grapheme
204  for (const ch of text) {
205    const cp = ch.codePointAt(0) ?? 0
206    if (joined) {
207      joined = false
208      if (cp >= 0x1f000 || WIDE_BMP.has(cp) || cp === 0x2640 || cp === 0x2642 || cp === 0x2695 || cp === 0x2764) continue
209    }
210    if (cp === 0x200d) joined = true
211    if (cp === 0xfe0f) w += 1
212    else if (cp === 0x200d || (cp >= 0x300 && cp <= 0x36f)) w += 0
213    else if (cp >= 0x1f000 || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xff00 && cp <= 0xff60) || WIDE_BMP.has(cp)) w += 2
214    else w += 1
215  }
216  return w
217}
218const WIDE_BMP = new Set([0x231a, 0x231b, 0x23e9, 0x23ea, 0x23eb, 0x23ec, 0x23f0, 0x23f3, 0x2614, 0x2615, 0x26a1, 0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x2753, 0x2754, 0x2755, 0x2757, 0x2b50, 0x2b55])
219export const lineWidth = (line: Line): number => displayWidth(plain(line))
220
221/** Drop whole trailing parts until the line fits `columns`; a lone part is kept (the surface truncates it). */
222export function fit(line: Line, columns: number): Line {
223  const parts: Line[] = [[]]
224  for (const r of line) {
225    if (r === SEPARATOR) parts.push([])
226    else parts[parts.length - 1]?.push(r)
227  }
228  while (parts.length > 1 && lineWidth(parts.flatMap((p, k) => (k === 0 ? p : [SEPARATOR, ...p]))) > columns) parts.pop()
229  return parts.flatMap((p, k) => (k === 0 ? p : [SEPARATOR, ...p]))
230}
231
plugin/core/setup.ts 336 lines
1// /census-setup: the questions, what an answer means, and the settings.json surgery. Pure: no `$`.
2import { expandHome, joinPath } from './home'
3import type { HomeEnv } from './home'
4import { DEFAULT_SEGMENTS } from './render'
5
6export type RecordMode = 'yes' | 'shadow' | 'no'
7export type Preset = 'two' | 'compact' | 'minimal'
8export type Placement = 'above' | 'below'
9
10/** What the person answered, in $.store. Absent = not answered; the defaults below apply. */
11export type Saved = {
12  offered?: boolean
13  record?: RecordMode
14  draw?: boolean
15  placement?: Placement
16  preset?: Preset
17  pr?: boolean
18  answeredAt?: number
19}
20
21export const SETUP_KEY = 'census-mod:setup'
22export const BACKUP_FILE = 'census-mod.statusline.json'
23export const SHADOW_DIRNAME = 'census-shadow'
24
25export const PRESETS: Record<Preset, string> = {
26  two: DEFAULT_SEGMENTS,
27  compact: 'context,cache,limits,cost,model,git',
28  minimal: 'context,limits/git',
29}
30
31// ---- what is on this machine ------------------------------------------------------------------
32
33export type Detection = {
34  /** This account's settings.json `statusLine.command`; null when there is none. */
35  statusLineCommand: string | null
36  /** Whether that command (or the script it runs) carries census's ingest block. */
37  ingestBlock: boolean
38  /** A census status-line writer touched the real store in the last few minutes. */
39  otherWriter: boolean
40  /** settings.json exists but is not valid JSON: it is never edited. */
41  settingsInvalid: boolean
42  /** The census PLUGIN is enabled for this account (`census@<marketplace>` keys): census-mod replaces it. */
43  censusPlugins: string[]
44}
45
46export const NO_DETECTION: Detection = { statusLineCommand: null, ingestBlock: false, otherWriter: false, settingsInvalid: false, censusPlugins: [] }
47
48/** The enabled `census@<marketplace>` plugin keys in a settings.json `enabledPlugins` (census-mod does not count). */
49export function enabledCensusPlugins(data: Record<string, unknown>): string[] {
50  const enabled = data.enabledPlugins
51  if (!enabled || typeof enabled !== 'object' || Array.isArray(enabled)) return []
52  return Object.entries(enabled as Record<string, unknown>)
53    .filter(([key, on]) => on === true && /^census@[^@]+$/.test(key))
54    .map(([key]) => key)
55    .sort()
56}
57
58// Census's own sentinel (plugins/census/scripts/statusline.py START): the line that opens the block it adds.
59export const INGEST_MARKER = '# --- census: record status-line payload'
60export const hasIngestBlock = (text: string): boolean => text.includes(INGEST_MARKER)
61/** A command that is itself census recording: `census ingest` or `census statusline`. */
62export const commandIsCensus = (command: string): boolean =>
63  /(^|[\s/\\'"])census(\.exe)?\s+(ingest|statusline)\b/.test(command) ||
64  // Windows: `census install` points the status line at its launcher, `python "<census dir>\launcher.py" statusline`.
65  /launcher\.py['"]?\s+(ingest|statusline)\b/.test(command)
66
67/**
68 * The files a status-line command runs, as absolute paths: `bash ~/.claude/line.sh`, `"$HOME/x.sh" --flag`,
69 * `/abs/x`, and on Windows `powershell -File "C:\Users\u\my line.ps1"` (quoted, with spaces and backslashes).
70 */
71export function scriptCandidates(command: string, home: string | undefined, userProfile?: string): string[] {
72  const out: string[] = []
73  const root = home?.replace(/[\\/]+$/, '')
74  const profile = (userProfile ?? home)?.replace(/[\\/]+$/, '') // %USERPROFILE% is its own variable
75  for (const m of command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
76    const t = m[1] ?? m[2] ?? m[3] ?? ''
77    let p = t
78    if (root && (t === '~' || /^~[\\/]/.test(t))) p = root + t.slice(1)
79    else if (profile && /^%USERPROFILE%[\\/]/.test(t)) p = profile + t.slice(t.search(/[\\/]/))
80    else if (root && /^(\$HOME|\$\{HOME\})[\\/]/.test(t)) p = root + t.slice(t.search(/[\\/]/))
81    if ((p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')) && !out.includes(p)) out.push(p)
82  }
83  return out
84}
85
86export type Parsed = { ok: true; data: Record<string, unknown> } | { ok: false }
87export function parseSettings(text: string): Parsed {
88  try {
89    const data = JSON.parse(text) as unknown
90    return data && typeof data === 'object' && !Array.isArray(data) ? { ok: true, data: data as Record<string, unknown> } : { ok: false }
91  } catch {
92    return { ok: false }
93  }
94}
95
96export function statusLineCommand(data: Record<string, unknown>): string | null {
97  const sl = data.statusLine as { command?: unknown } | undefined
98  return sl && typeof sl === 'object' && typeof sl.command === 'string' ? sl.command : null
99}
100
101/** A census writer is active: a session entry written in the last `withinMs` that the mod did not write. */
102export function writerActive(entries: { updatedAt: number; hasCensusMod: boolean }[], nowMs: number, withinMs = 180_000): boolean {
103  return entries.some(e => !e.hasCensusMod && nowMs - e.updatedAt <= withinMs)
104}
105
106// ---- settings: env > $.store > defaults ----------------------------------------------------------
107
108export type SetupEnv = HomeEnv & { CENSUS_MOD_STORE?: string; CENSUS_STATUSLINE_SEGMENTS?: string; CENSUS_MOD_PLACEMENT?: string }
109export type Effective = { record: RecordMode; shadowDir: string | null; draw: boolean; placement: Placement; segments: string | undefined; pr: boolean }
110
111/**
112 * Before any answer: record to the real store ONLY if this account's status line does not carry the
113 * census ingest block (else a second writer would share the store), draw yes, gh yes. An answer replaces
114 * the default; an environment variable replaces both.
115 */
116export function effective(saved: Saved, env: SetupEnv, det: Detection, configRoot: string | null): Effective {
117  const envShadow = env.CENSUS_MOD_STORE?.trim()
118  const shadowAt = configRoot ? joinPath(configRoot, SHADOW_DIRNAME) : null
119  let record: RecordMode = saved.record ?? (det.ingestBlock ? 'no' : 'yes')
120  let shadowDir: string | null = record === 'shadow' ? shadowAt : null
121  if (envShadow) {
122    record = 'shadow'
123    shadowDir = expandHome(envShadow, env)
124  }
125  if (record === 'shadow' && !shadowDir) record = 'no' // nowhere to write
126  const envSegments = env.CENSUS_STATUSLINE_SEGMENTS?.trim()
127  return {
128    record,
129    shadowDir,
130    draw: saved.draw ?? true,
131    // An install that never answered stays where it was (above); the question recommends below for new ones.
132    placement: ((p: string | undefined): Placement | null => (p === 'above' || p === 'below' ? p : null))(env.CENSUS_MOD_PLACEMENT?.trim().toLowerCase()) ?? saved.placement ?? 'above',
133    segments: envSegments || (saved.preset ? PRESETS[saved.preset] : undefined),
134    pr: saved.pr ?? true,
135  }
136}
137
138// ---- the questions (labels only; the recommendation rides in the label) -------------------------------
139
140export type Question = { header: string; question: string; options: string[] }
141const REC = ' (Recommended)'
142
143export const L = {
144  offerYes: 'Set it up now',
145  offerLater: 'Not now — use the defaults',
146  exStop: "Stop — I'll disable census first",
147  exGo: 'Continue anyway (two writers)',
148  recReplace: 'Replace my status line — census-mod records and draws it',
149  recYes: 'Yes — record into census (dashboards, vitals and liveness use it)',
150  recShadow: "Shadow — record into a separate store to compare; dashboards won't see it",
151  recNo: "No — don't record (dashboards and vitals won't see this account)",
152  recNoFed: 'No — leave recording to my status line (it keeps feeding census)',
153  recNoOther: "No — don't record (whatever else writes keeps feeding census)",
154  drawBelow: "Yes — below the input, under Claude Code's hint line",
155  drawAbove: 'Yes — above the input, in the band',
156  drawNo: 'No — record only, draw nothing',
157  wRemove: "Remove this account's status line (census-mod draws it instead)",
158  wKeep: "Keep my status line; census-mod won't record",
159  wBoth: 'Keep both (not recommended)',
160  presetTwo: 'Your two lines',
161  presetCompact: 'Compact — one line',
162  presetMinimal: 'Minimal — context, limits / git',
163  prYes: 'Yes',
164  prNo: "No — never call gh",
165} as const
166
167const rec = (s: string, on: boolean) => (on ? `${s}${REC}` : s)
168
169export const Q = {
170  offer: (): Question => ({
171    header: '🧭 Setup',
172    question: 'census-mod is installed. Set it up now? It takes a few questions: whether to record sessions into census, whether and where to draw the status line, and which layout.',
173    options: [rec(L.offerYes, true), L.offerLater],
174  }),
175  /** census and census-mod are alternatives: with the census plugin enabled there would be two writers. */
176  exclusive: (keys: string[]): Question => ({
177    header: '⚠️ census',
178    question: `census-mod replaces the census plugin, and ${keys.join(', ')} is still enabled here. Disable it first (${keys.map(k => `claude plugin disable ${k}`).join('; ')}), or two writers will share one store. Stop here, or continue anyway?`,
179    options: [rec(L.exStop, true), L.exGo],
180  }),
181  /**
182   * Almost nobody else records into census, so the common path is Yes or No. Shadow (a separate store to compare
183   * in) and Replace (swap a status line that feeds census for census-mod) are offered only where something already
184   * records into the real store.
185   */
186  record: (det: Detection, shadowByEnv = false): Question => {
187    const readers = 'the overseer dashboard, /census-mod:vitals and session liveness'
188    const OVERSEER = 'the overseer dashboard, /census-mod:vitals and liveness'
189    if (det.ingestBlock && shadowByEnv) {
190      return {
191        header: '📝 Record',
192        question: `CENSUS_MOD_STORE is set, so census-mod records into a separate shadow store whatever you answer, and your status line — which records this account's sessions into census, the store ${OVERSEER} read — stays as that store's writer. Unset it and run /census-setup again to replace the status line. Compare in the shadow store, or leave recording to your status line?`,
193        options: [rec(L.recShadow, true), L.recYes, L.recNoFed],
194      }
195    }
196    if (det.ingestBlock) {
197      return {
198        header: '📝 Record',
199        question: `Your status line already records this account's sessions into census — the store ${OVERSEER} read. Replace it with census-mod (records and draws the line; yours is backed up), compare first, or leave recording to your status line (it keeps feeding census)?`,
200        options: [rec(L.recReplace, true), L.recShadow, L.recYes, L.recNoFed],
201      }
202    }
203    if (det.otherWriter) {
204      return {
205        header: '📝 Record',
206        question: `Something has been recording this account's sessions into census in the last few minutes — the store ${OVERSEER} read. Two writers on one store muddle liveness: compare first in a separate store, or record anyway?`,
207        options: [rec(L.recShadow, true), L.recYes, L.recNoOther],
208      }
209    }
210    return {
211      header: '📝 Record',
212      question: `Record this account's sessions into census? That's what ${readers} read (context, cost, limits, git, PR) — without it they see nothing from this account.`,
213      options: [rec(L.recYes, true), L.recNo],
214    }
215  },
216  place: (): Question => ({
217    header: '🎛️ Draw',
218    question: 'Where should census-mod draw your status line?',
219    options: [rec(L.drawBelow, true), L.drawAbove],
220  }),
221  draw: (): Question => ({
222    header: '🎛️ Draw',
223    question: 'Should census-mod draw your status line, and where?',
224    options: [rec(L.drawBelow, true), L.drawAbove, L.drawNo],
225  }),
226  writers: (draw: boolean, otherWriter: boolean): Question => ({
227    header: '⚠️ Writers',
228    question: `This account's status line also records into the census store${otherWriter ? ' (and it has written in the last few minutes)' : ''}. Two writers on one store muddle idle and liveness. What now?`,
229    options: draw ? [rec(L.wRemove, true), L.wKeep, L.wBoth] : [rec(L.wKeep, true), L.wBoth],
230  }),
231  preset: (): Question => ({
232    header: '📐 Layout',
233    question: 'Which segments in the band? (CENSUS_STATUSLINE_SEGMENTS still overrides this.)',
234    options: [rec(L.presetTwo, true), L.presetCompact, L.presetMinimal],
235  }),
236  pr: (): Question => ({
237    header: '🔀 PR',
238    question: "Show the branch's open PR (number, review state) using gh? No means gh is never called.",
239    options: [rec(L.prYes, true), L.prNo],
240  }),
241}
242
243const strip = (label: string): string => label.replace(REC, '')
244export const is = (answer: string, label: string): boolean => strip(answer) === label || strip(answer).trim() === label
245
246export function recordFrom(answer: string): RecordMode | null {
247  if (is(answer, L.recYes) || is(answer, L.recReplace)) return 'yes'
248  if (is(answer, L.recShadow)) return 'shadow'
249  if (is(answer, L.recNo) || is(answer, L.recNoFed) || is(answer, L.recNoOther)) return 'no'
250  return null
251}
252/** The record answer that also hands the status line over to census-mod. */
253export const replaceFrom = (answer: string): boolean => is(answer, L.recReplace)
254/** The draw answer as a placement; null means "don't draw". */
255export function placementFrom(answer: string): Placement | null {
256  if (is(answer, L.drawAbove)) return 'above'
257  if (is(answer, L.drawBelow)) return 'below'
258  return null
259}
260export function presetFrom(answer: string): Preset | null {
261  if (is(answer, L.presetTwo)) return 'two'
262  if (is(answer, L.presetCompact)) return 'compact'
263  if (is(answer, L.presetMinimal)) return 'minimal'
264  return null
265}
266export type Writers = 'remove' | 'keep' | 'both'
267export function writersFrom(answer: string): Writers | null {
268  if (is(answer, L.wRemove)) return 'remove'
269  if (is(answer, L.wKeep)) return 'keep'
270  if (is(answer, L.wBoth)) return 'both'
271  return null
272}
273export const yes = (answer: string, label: string): boolean | null => (is(answer, label) ? true : null)
274
275// ---- settings.json: remove the statusLine, back it up exactly, restore it ------------------------------
276
277function indentOf(text: string): string | number {
278  const m = /\n([ \t]+)"/.exec(text)
279  return m ? (m[1] ?? 2) : 2
280}
281const serialise = (data: Record<string, unknown>, like: string): string => `${JSON.stringify(data, null, indentOf(like))}\n`
282
283export type Removal = { ok: true; text: string; backup: string } | { ok: false; reason: 'invalid' | 'none' }
284
285/** settings.json without its `statusLine`, every other key and its order kept; the backup holds the removed value verbatim. */
286export function removeStatusLine(text: string, settingsPath: string, nowIso: string): Removal {
287  const p = parseSettings(text)
288  if (!p.ok) return { ok: false, reason: 'invalid' }
289  if (!('statusLine' in p.data)) return { ok: false, reason: 'none' }
290  const rest = Object.fromEntries(Object.entries(p.data).filter(([k]) => k !== 'statusLine'))
291  return { ok: true, text: serialise(rest, text), backup: `${JSON.stringify({ statusLine: p.data.statusLine, removedAt: nowIso, from: settingsPath }, null, 2)}\n` }
292}
293
294export type Restoration =
295  | { done: 'restored'; text: string }
296  | { done: 'already' }
297  | { done: 'kept'; why: 'present' | 'invalid' | 'no-backup' }
298
299const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
300
301/** Put the backed-up statusLine back, but only into a settings.json that has none (or already has exactly it). */
302export function restoreStatusLine(text: string, backupText: string | null): Restoration {
303  let backup: { statusLine?: unknown } | null = null
304  try {
305    const parsed: unknown = backupText === null ? null : JSON.parse(backupText)
306    // A scalar, an array or null is not a backup: nothing to put back.
307    backup = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as { statusLine?: unknown }) : null
308  } catch {
309    backup = null
310  }
311  if (!backup || !('statusLine' in backup)) return { done: 'kept', why: 'no-backup' }
312  const p = parseSettings(text)
313  if (!p.ok) return { done: 'kept', why: 'invalid' }
314  if ('statusLine' in p.data) return same(p.data.statusLine, backup.statusLine) ? { done: 'already' } : { done: 'kept', why: 'present' }
315  return { done: 'restored', text: serialise({ ...p.data, statusLine: backup.statusLine }, text) }
316}
317
318export const settingsTmp = (path: string): string => `${path}.census-mod.tmp`
319
320/** An existing backup holding a DIFFERENT status line than the one about to be removed: keep it, refuse the removal. */
321export function backupBlocks(existing: string | null, newBackup: string): boolean {
322  const statusOf = (t: string): unknown => {
323    try {
324      return (JSON.parse(t) as { statusLine?: unknown }).statusLine
325    } catch {
326      return undefined
327    }
328  }
329  if (existing === null) return false
330  const old = statusOf(existing)
331  return old !== undefined && !same(old, statusOf(newBackup))
332}
333
334/** The answer to the mutual-exclusion question: true to go on despite the census plugin. */
335export const continueAnyway = (answer: string): boolean => is(answer, L.exGo)
336