The next TODO items and a live sync status line in a band above the prompt (kept until dismissed, or optionally one toast at session start), TODO_SYNC drift…

A view over a project's TODO catalog and the harness plugin's todo_sync checks, written as a CLI plugin of function hooks. It shows the next TODO items with a small sync status line when a session starts, adds a TODO_SYNC drift line to the prompt context, and answers /todo with the top items and every finding. The notice is a band above the prompt by default, kept until its Dismiss button is pressed, its status line following each sync change; todo.notice.style: "toast" posts it once as a toast instead, kept for todo.notice.toast_seconds (60 s, the engine's ceiling). The sync rules themselves live in Python (harness_lib.todo_sync behind scripts/todo_sync.py); the mod only reads, gates and displays.
Every hook fails open: an unreadable file, a missing harness config, a missing runner pointer, a refused or timed-out process run, or a malformed report or catalog degrades to "nothing to show". No hook blocks or fails a tool call, a prompt or a turn on its own error.
The watch is active only in a project whose .claude/harness.json reads and sets todo.folder to a non-blank path; that one key is the whole setup ("todo": {"folder": "docs/RESEARCH/_TODO"}). Candidates for the project root are the session cwd and each folder above it, nearest first (a subfolder, or a worktree nested inside its checkout, finds the checkout's file), then CLAUDE_PROJECT_DIR; the first that holds .claude/harness.json is the root. Finding the root spawns no process. With todo.folder null, or no harness file, the mod registers no command, shows no notice and spawns nothing. The TODO paths are resolved under todo.folder (.. folds), as harness_lib resolves them. The folder and artifacts.* are project-relative: they are joined to the root, and an absolute value is not supported. The harness schema rejects it (relPath), and a rooted or drive-prefixed todo.folder leaves the mod inactive, as a null one does, so it never reads files outside the project.
An installed mod lives in its own plugin cache folder and cannot read the harness plugin's config/defaults.json, so hooks/config.ts carries copies of the values it needs and merges the project's harness.json over them (objects key by key, scalars replace; malformed JSON or a wrong-typed value keeps the default):
| Key | Default | Used for |
|---|---|---|
state_dir | .claude/state/harness | <state_dir>/todo-sync/runner.json (runner pointer) and report.json (sync report) |
artifacts.research | docs/RESEARCH | watched folder (writes under it re-check) |
artifacts.plans | docs/PLANS | watched folder |
todo.folder | null | where the TODO files live; null turns the mod off |
todo.catalog | RepoToDo.json (under the folder) | the TODO catalog the summary reads; watched file |
todo.catalog_schema | RepoToDo.schema.json (under the folder) | watched file; null: not watched |
todo.top_n | 10 | rows in the /todo table (the catalog read keeps max(top_n, notice.items) rows) |
todo.snapshots.dir | Snapshots (under the folder) | watched folder; null: not watched |
todo.research.catalog | ResearchImplementationCatalog.json (under the folder) | watched file; null: not watched |
todo.gh.max_age_hours | 24 | gh <age> old suffix once the issue cache in the report is older |
todo.inject_context | true | whether prompts carry the TODO_SYNC line |
todo.notice.style | banner | banner or toast (M7); another value keeps the default |
todo.notice.toast_seconds | 60 | seconds a toast stays; a value outside (0, 60] keeps the default |
todo.notice.items | 3 | item lines in the notice (M1); a value that is not a whole number from 1 to 10 keeps the default |
tests/mods/test_todo_watch_config_mirror.py fails when defaults.json changes one of these values without config.ts following.
| # | Rule | Behavior | ||||
|---|---|---|---|---|---|---|
| M1 | The next items are visible when a session starts | One notice at session.start (also after /clear or a resume): the first todo.notice.items catalog items by rank, one <rank> <title> line each (the title on one line, clipped to 80 characters), then one status line: ✓ in sync (theme color success) once a fresh report has no drift, <N> out of sync (warning) when it has drift, sync checks unavailable (dim) after the runner retry finds none, and no status line before any of these. A shorter catalog shows fewer lines; an unreadable one shows none. A view with no item line and no status shows nothing at all. A report older than the session start is never shown. | ||||
| M2 | The model sees drift without anyone pasting it | TODO_SYNC drift=4 G2=1 P1=1 S5=1 on prompt.submit (rule ids sorted by letter, then number), attached only when it changed since the line last attached, and again after /clear, a resume or a main compaction. No line before a report newer than the session start; TODO_SYNC unavailable once when checks are unavailable. | ||||
| M3 | Changing a watched file re-checks within the turn | After a Write, Edit, MultiEdit or NotebookEdit call (file_path, or notebook_path) on a watched path (equal to the resolved TODO catalog, catalog schema or research catalog, or under artifacts.research, artifacts.plans or the snapshots folder; backslashes and a project-relative spelling accepted, case-insensitive on a Windows-style root), one check runs. After Bash / PowerShell, which name no path, the watched files and folders (and the folders' top-level entries, plus the entries one level under each artifacts.research subfolder) are stat-ed; a check runs only when one mtime is newer than the last check start (else the last report). Any other call spawns nothing. | ||||
| M3b | Edits made outside the session are re-checked too | The same mtime probe on prompt.submit and on the main turn.complete. | ||||
| M4 | Sync changes never nag | A new report, a catalog edit or checks becoming unavailable only recompute the notice view: a shown band updates its lines and status in place. Nothing ever shows, re-shows or posts on its own after the session start. | ||||
| M5 | Full detail on demand | /todo prints the summary line (with · 4 out of sync, · sync clean and · gh 3d old once a fresh report says so), the top-N table (`Rank \ | Id \ | Status \ | Score \ | Title), then sync drift N: <rules> and each finding as rule severity file:line subject: message (fix), or sync clean, sync checks unavailable, or no report yet`. |
| M6 | The summary needs no engine | The mod reads the TODO catalog itself; the catalog and the report are re-read only when their mtime moved ($.fs.stat). | ||||
| M7 | The notice shows as the project chose | todo.notice.style banner (default): the band above the prompt (ui.render on AbovePrompt) draws one live copy of the notice, each item line cut at the terminal edge, with a Dismiss button beside the status line (beside the last item line while there is no status). Dismiss hides it for the rest of the session; the next session start shows it again. toast: one toast at session start with the item lines (no sync status is known yet), its timeoutMs = todo.notice.toast_seconds x 1000 (a click takes it off sooner, the pointer over it holds it); later changes post nothing. The band yields to a survey and passes while hidden or empty, so in toast style it never draws. |
The harness SessionStart hook writes <state_dir>/todo-sync/runner.json: {"schema_version": 1, "python", "script", "project", "report", "written_at"}. The mod looks it up at session.start; when it is missing (or its script does not exist), it looks once more on the first prompt.submit or main turn.complete. Only after that retry also finds none does it report sync checks unavailable (on /todo, the context line and a shown band's status line; no new notice). While the lookup is pending nothing says unavailable.
A check runs [python, script, "check", "--project", project] through $.process.run with a 20 s cap, always after the dispatch that asked for it ($.clock.after(0, ...)), so no tool result or prompt waits on it. One run is in flight at a time: a request while the engine process runs is owed one run after it, a request before it starts folds into it. The pointer is read again before each run. The engine writes the report itself and its stdout is ignored; a refusal, a timeout or a non-zero exit shows nothing. After a run of its own the mod re-reads report.json (the pointer's report, else <state_dir>/todo-sync/report.json) and keeps it only when its generated_at (else its mtime) is not older than the session start, floored to the whole second (a check finishing in the start's own second may carry a whole-second stamp).
The report fields read are pinned to plugins/harness/schemas/TodoSyncReport.schema.json by tests/mods/test_todo_watch_report_contract.py: schema_version (must be 1), generated_at, drift, by_rule, counts.error / counts.warn, gh_fetched_at and findings[] (rule, severity, counted, file, line, subject, message, fix). A report missing one of the top-level keys is ignored; a malformed finding (or one whose severity is not error / warn) is skipped. A report from a check --rules run (a rules_skipped reason not selected) covers only the selected rules and is ignored, so a manual or test-suite subset run never shows as the project's drift. The same test pins the runner pointer's schema_version and the keys parseRunner requires (python, script, project) to what the harness write_runner emits, and checks that the finding parser reads its keys and severities through the pinned constants.
| Hook | Does |
|---|---|
session.start | Registers /todo, shows the start notice, looks up the runner pointer and schedules the first check. |
tool.call on Bash, PowerShell, Edit, Write, MultiEdit, NotebookEdit | After the call returns, schedules the path gate or the mtime probe (M3). |
turn.complete (main loop) | The runner retry, then the catalog re-read and the mtime probe (M3b). |
prompt.submit | The runner retry, the report re-read, the probe, and the TODO_SYNC line (M2). |
session.end, session.compact | After /clear, a resume or a main compaction, the next prompt gets the line again. |
command.run on todo | Answers /todo while the mod registered it; otherwise passes the command on. |
ui.render on AbovePrompt | Draws the notice band with its Dismiss button (M7, banner style); passes while hidden, empty or under a survey. |
$.state['todo-watch'] (contract: types/index.d.ts):
| Key | Meaning |
|---|---|
summary | The catalog's counts and its top max(todo.top_n, todo.notice.items) items, with the catalog mtime; null when unreadable. |
report | The latest report newer than the session start (drift, by rule, counts, issue cache time, findings, mtime). |
lastContext | The TODO_SYNC line last attached to a prompt. |
notice | The one live notice view (item lines, then the status text and tone), recomputed on every catalog, report or checks-status change; null at a session start. |
isNoticeShown | Whether the band draws the notice: set at session start (banner style, non-empty view), cleared by Dismiss and at the next session start. |
.claude/harness.json created mid-session is seen after a reload. The file itself is read afresh on every hook, so a todo change in it applies at once..claude/harness.json between the cwd and the checkout root wins over the checkout's own, where the harness library's project_root (git's top level) would pick the checkout's.~/.config/harness/worktrees default) is not walked up to the main checkout. It finds only its own tracked .claude/harness.json, else falls back to CLAUDE_PROJECT_DIR. The mod then watches the main checkout while the harness hook (git's top level) resolves the worktree.artifacts.research one level deeper (the <DOMAIN>/<stub> files, so an in-place stub edit is seen); a change deeper than that made by a shell command or an IDE is seen only when it moves a watched file or a listed entry.claude plugin validate --strict <mod folder>
claude plugin test <mod folder>
hooks/testWorld.ts is the mocked world the hook tests share (never imported by register.ts): the plugin test sandbox cannot read repository files, so every $ call is answered from memory, under /repo and C:/repo roots alike.
Install from the operator-straps marketplace:
claude plugin marketplace add t968rs/operator-straps
claude plugin install todo-watch@operator-straps
For development, claude --plugin-dir <folder> loads the folder for one session, or a CLAUDE_CODE_PLUGIN_DIRS entry in the user-level ~/.claude/settings.json env block loads it with hot reload (see plugins/pr-review-watch/README.md, Loading).
.claude-plugin/types/ is generated by the engine on load and git-ignored; tsconfig.json extends it.
hooks/register.ts 22 lines1import type { Register } from 'claude-code'
2
3import { registerBand } from './band'
4import { registerChecks, registerContext, startSession } from './checks'
5import { registerCommand, registerTodoCommand } from './command'
6
7const noop = (): undefined => undefined
8
9export const register: Register = on => {
10 // One session.start hook (the engine refuses two without a matcher): the command, then the start notice.
11 on('session.start', async ($, e, next) => {
12 const started = await next(e)
13 await registerTodoCommand($).catch(noop)
14 await startSession($).catch(noop)
15 return started
16 })
17 registerChecks(on)
18 registerContext(on)
19 registerCommand(on)
20 registerBand(on)
21}
22hooks/band.tsx 73 lines1import type { EngineInterface, On } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { NoticeTone, NoticeView } from '../types'
5import { isEmptyView } from './summary'
6
7/**
8 * The todo-watch band above the prompt, used when `todo.notice.style` is `banner` (the default): the top
9 * items, then the sync status, shown at session start and kept until the Dismiss button is pressed. One live
10 * view: a catalog or sync change rewrites it in place and never shows the band again. Functions taking `$`
11 * are function declarations: the engine follows `$` past an import only into one.
12 */
13
14type Dollar = EngineInterface
15
16const noticeAtom = atom({ plugin: 'todo-watch', key: 'notice' } as const, null)
17const shownAtom = atom({ plugin: 'todo-watch', key: 'isNoticeShown' } as const, false)
18
19const noop = (): undefined => undefined
20
21/** Replaces the live view; a shown band draws it at once, a hidden one stays hidden. Never throws. */
22export async function setNoticeView($: Dollar, view: NoticeView): Promise<void> {
23 await update($, noticeAtom, () => view).catch(noop)
24}
25
26/** Shows the band (written even when already shown). Never throws. */
27export async function showBand($: Dollar): Promise<void> {
28 await update($, shownAtom, () => true).catch(noop)
29}
30
31/** Hides the band for the rest of the session. Never throws. */
32export async function hideBand($: Dollar): Promise<void> {
33 await update($, shownAtom, () => false).catch(noop)
34}
35
36/** Hides the band and forgets its view, as a session start does. Never throws. */
37export async function resetBand($: Dollar): Promise<void> {
38 await update($, shownAtom, () => false).catch(noop)
39 await update($, noticeAtom, () => null).catch(noop)
40}
41
42/** The status `Text` color: a theme key, so a light terminal theme stays readable; dim when unavailable. */
43const toneProps = (tone: NoticeTone): { color: 'success' | 'warning' } | { dimColor: true } =>
44 tone === 'dim' ? { dimColor: true } : { color: tone === 'ok' ? 'success' : 'warning' }
45
46/**
47 * The band: each item line, cut at the terminal edge, then the status line with Dismiss beside it (beside the
48 * last item line while there is no status); nothing while hidden, empty, or a survey shows.
49 */
50export function registerBand(on: On): void {
51 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
52 if (e.props.hasSurvey || !(await read($, shownAtom))) return next(e)
53 const view = await read($, noticeAtom)
54 if (view === null || isEmptyView(view)) return next(e)
55 const { Box, Button, Text } = $.ui.resolve(e)
56 const dismiss = <Button key="dismiss" label="Dismiss" role="dismiss" onPress={() => hideBand($)} />
57 const { status } = view
58 const lines = status === null ? view.items.slice(0, -1) : view.items
59 const last = view.items[view.items.length - 1]
60 return (
61 <Box flexDirection="column">
62 {lines.map(line => <Text wrap="truncate-end">{line}</Text>)}
63 <Box>
64 {status === null
65 ? <Text wrap="truncate-end">{last} </Text>
66 : <Text {...toneProps(status.tone)}>{status.text} </Text>}
67 {dismiss}
68 </Box>
69 </Box>
70 )
71 })
72}
73hooks/checks.ts 590 lines1import type {
2 EngineInterface,
3 On,
4 PromptSubmitResult,
5 SessionCompactInput,
6 SessionCompactResult,
7} from 'claude-code'
8import { atom, read, update } from 'claude-code'
9
10import type { NoticeView, SyncFinding, SyncReport, TodoSummary } from '../types'
11import { resetBand, setNoticeView, showBand } from './band'
12import { isAbsolute, syncDirOf, under, type TodoWatchConfig } from './config'
13import {
14 contextLineFor, formatNotice, formatTodoBody, ghAgeText, isEmptyView, noticeText, parseCatalog,
15 type SyncStatus,
16} from './summary'
17import { resolveWatch, type Watch } from './watch'
18
19/**
20 * Engine glue for todo-watch: the start notice and its live view, the runner pointer, gated and coalesced
21 * check runs, the sync report (re-read only when its mtime moved) and the TODO_SYNC prompt line.
22 * Active only in a project whose `.claude/harness.json` sets `todo.folder` (./watch). Every host failure
23 * degrades to showing nothing; no hook throws into a tool call, a prompt or a turn. Functions taking `$`
24 * are function declarations: the engine follows `$` past an import only into one.
25 */
26
27type Dollar = EngineInterface
28
29// ---------------------------------------------------------------------------
30// Contracts with the harness todo_sync engine (pinned by tests/mods/test_todo_watch_report_contract.py)
31
32/** TodoSyncReport `schema_version` the mod reads; another version is ignored. */
33export const REPORT_SCHEMA_VERSION = 1
34/** Top-level report keys the mod reads: each must be present. */
35export const REPORT_KEYS = [
36 'schema_version', 'generated_at', 'drift', 'by_rule', 'counts', 'gh_fetched_at', 'findings',
37] as const
38/** `counts` keys the mod reads. */
39export const COUNT_KEYS = ['error', 'warn'] as const
40/** Finding keys the mod reads; `findingOf` reads each through `field`, which accepts no other key. */
41export const FINDING_KEYS = [
42 'rule', 'severity', 'counted', 'file', 'line', 'subject', 'message', 'fix',
43] as const
44/** Finding keys that must be present; the rest default to null. */
45export const FINDING_REQUIRED = ['rule', 'severity', 'counted', 'file', 'message'] as const
46/** Finding severities the mod accepts; a finding with another one is skipped. */
47export const SEVERITIES = ['error', 'warn'] as const
48/**
49 * `rules_skipped` reason of a rule left out by `check --rules`: a report carrying it covers a subset of the
50 * rules, not the whole project, and is ignored.
51 */
52export const SUBSET_REASON = 'not selected'
53/** Runner pointer `schema_version` the mod reads. */
54export const RUNNER_SCHEMA_VERSION = 1
55/** Runner pointer keys that must be non-blank; `report` is optional. */
56export const RUNNER_KEYS = ['python', 'script', 'project'] as const
57
58/** Longest a check run may take before it is killed and fails open, in milliseconds. */
59export const CHECK_TIMEOUT_MS = 20_000
60/** Tools whose calls may change a watched file. */
61export const WATCH_TOOLS = /^(Bash|PowerShell|Edit|Write|MultiEdit|NotebookEdit)$/
62const SHELL_TOOLS = /^(Bash|PowerShell)$/
63
64/** The interpreter, script, project and report the harness SessionStart hook recorded. */
65export type Runner = { python: string; script: string; project: string; report: string | undefined }
66
67type Json = Readonly<Record<string, unknown>>
68
69const noop = (): undefined => undefined
70const isObject = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
71const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0
72const nonBlank = (v: unknown): string | undefined =>
73 typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
74const stringOrNull = (v: unknown): string | null => (typeof v === 'string' ? v : null)
75
76const parseJson = (text: string): unknown => {
77 try {
78 return JSON.parse(text)
79 } catch {
80 return undefined
81 }
82}
83
84// ---------------------------------------------------------------------------
85// Pure: runner pointer and report
86
87/** The runner pointer (`runner.json`); undefined when malformed or of another version. */
88export const parseRunner = (text: string): Runner | undefined => {
89 const data = parseJson(text)
90 if (!isObject(data) || data.schema_version !== RUNNER_SCHEMA_VERSION) return undefined
91 const [python, script, project] = RUNNER_KEYS.map(k => nonBlank(data[k]))
92 if (python === undefined || script === undefined || project === undefined) return undefined
93 return { python, script, project, report: nonBlank(data.report) }
94}
95
96type FindingKey = (typeof FINDING_KEYS)[number]
97type Severity = (typeof SEVERITIES)[number]
98
99/** A finding's value at one of `FINDING_KEYS`. */
100const field = (v: Json, key: FindingKey): unknown => v[key]
101
102const isSeverity = (v: unknown): v is Severity => SEVERITIES.some(s => s === v)
103
104/** One finding; undefined when a required key is missing or mistyped. */
105const findingOf = (v: unknown): SyncFinding | undefined => {
106 if (!isObject(v) || !FINDING_REQUIRED.every(k => k in v)) return undefined
107 const [rule, severity, counted, message] = [field(v, 'rule'), field(v, 'severity'), field(v, 'counted'),
108 field(v, 'message')]
109 if (typeof rule !== 'string' || typeof message !== 'string' || typeof counted !== 'boolean') return undefined
110 if (!isSeverity(severity)) return undefined
111 const line = field(v, 'line')
112 return {
113 rule, severity, counted, message, line: isCount(line) && line > 0 ? line : null,
114 file: stringOrNull(field(v, 'file')), subject: stringOrNull(field(v, 'subject')),
115 fix: stringOrNull(field(v, 'fix')),
116 }
117}
118
119/** Whether a report's `rules_skipped` marks a `check --rules` run (`SUBSET_REASON`). */
120const isSubsetRun = (skipped: unknown): boolean =>
121 Array.isArray(skipped) && skipped.some(s => isObject(s) && s.reason === SUBSET_REASON)
122
123/** Counted findings per rule id, positive whole numbers only. */
124const byRuleOf = (v: Json): Record<string, number> =>
125 Object.fromEntries(Object.entries(v).filter(([, n]) => isCount(n) && n > 0)) as Record<string, number>
126
127/**
128 * A TodoSyncReport text as the mod reads it; undefined when malformed, of another version, or written by a
129 * `check --rules` run (its drift covers only the selected rules).
130 */
131export const parseReport = (text: string, mtimeMs: number): SyncReport | undefined => {
132 const data = parseJson(text)
133 if (!isObject(data) || !REPORT_KEYS.every(k => k in data)) return undefined
134 if (data.schema_version !== REPORT_SCHEMA_VERSION || typeof data.generated_at !== 'string') return undefined
135 if (isSubsetRun(data.rules_skipped)) return undefined
136 const { drift, by_rule: byRule, counts, findings } = data
137 if (!isCount(drift) || !isObject(byRule) || !isObject(counts) || !Array.isArray(findings)) return undefined
138 return {
139 generatedAt: data.generated_at, drift, byRule: byRuleOf(byRule),
140 errors: isCount(counts[COUNT_KEYS[0]]) ? (counts[COUNT_KEYS[0]] as number) : 0,
141 warns: isCount(counts[COUNT_KEYS[1]]) ? (counts[COUNT_KEYS[1]] as number) : 0,
142 ghFetchedAt: stringOrNull(data.gh_fetched_at),
143 findings: findings.map(findingOf).filter((f): f is SyncFinding => f !== undefined),
144 mtimeMs,
145 }
146}
147
148/** When the report was made: `generated_at`, else its file mtime. */
149export const reportTimeMs = (r: SyncReport): number => {
150 const at = Date.parse(r.generatedAt)
151 return Number.isFinite(at) ? at : r.mtimeMs
152}
153
154// ---------------------------------------------------------------------------
155// Pure: watched paths
156
157/** The files a change to which re-checks: catalog, catalog schema, research catalog. */
158export const watchedFiles = (w: Watch): string[] =>
159 [w.cfg.catalog, w.cfg.catalogSchema, w.cfg.researchCatalog]
160 .filter((p): p is string => p !== null)
161 .map(p => under(w.root, p))
162
163/** The folders under which a change re-checks: research, plans, snapshots. */
164export const watchedDirs = (w: Watch): string[] =>
165 [w.cfg.researchDir, w.cfg.plansDir, w.cfg.snapshotsDir]
166 .filter((p): p is string => p !== null)
167 .map(p => under(w.root, p))
168
169/** Forward slashes, `.` and `..` folded, no trailing slash; the `/`, `//` or `C:/` head kept. */
170export const normalPath = (raw: string): string => {
171 const p = raw.trim().replace(/\\/g, '/')
172 const head = /^([A-Za-z]:\/|\/\/|\/)/.exec(p)?.[0] ?? ''
173 const segs: string[] = []
174 for (const s of p.slice(head.length).split('/')) {
175 if (s === '' || s === '.') continue
176 if (s === '..' && segs.length > 0 && segs[segs.length - 1] !== '..') segs.pop()
177 else segs.push(s)
178 }
179 return `${head}${segs.join('/')}`
180}
181
182const isWindowsStyle = (p: string): boolean => /^([A-Za-z]:|\/\/)/.test(p)
183
184/**
185 * Whether a tool's path (absolute, or relative to the project root) is a watched file or lies under a
186 * watched folder; case-insensitive on a Windows-style root.
187 */
188export const isWatchedPath = (w: Watch, raw: string): boolean => {
189 if (raw.trim() === '') return false
190 const path = normalPath(isAbsolute(raw.trim()) ? raw : `${w.root}/${raw}`)
191 const fold = isWindowsStyle(w.root) || isWindowsStyle(path)
192 const key = (p: string): string => (fold ? normalPath(p).toLowerCase() : normalPath(p))
193 const target = fold ? path.toLowerCase() : path
194 return (
195 watchedFiles(w).some(f => key(f) === target) ||
196 watchedDirs(w).some(d => target.startsWith(`${key(d)}/`))
197 )
198}
199
200/** The path a file tool names: `file_path`, or `notebook_path` for NotebookEdit; undefined for a shell. */
201type PathCall = { tool: unknown; file_path?: unknown; notebook_path?: unknown }
202
203export const pathOfCall = (e: PathCall): string | undefined => {
204 const raw = String(e.tool) === 'NotebookEdit' ? e.notebook_path : e.file_path
205 return typeof raw === 'string' ? raw : undefined
206}
207
208// ---------------------------------------------------------------------------
209// State
210
211const summaryAtom = atom({ plugin: 'todo-watch', key: 'summary' } as const, null)
212const reportAtom = atom({ plugin: 'todo-watch', key: 'report' } as const, null)
213const lastContextAtom = atom({ plugin: 'todo-watch', key: 'lastContext' } as const, '')
214
215/** Per-load session bookkeeping (a reload starts afresh; its `session.start` fires again). */
216const session = {
217 startedAtMs: undefined as number | undefined,
218 status: 'pending' as SyncStatus,
219 /** The runner lookup in flight, so a retry is made once. */
220 lookup: undefined as Promise<Runner | undefined> | undefined,
221 runner: undefined as Runner | undefined,
222 catalogMtime: undefined as number | undefined,
223 reportMtime: undefined as number | undefined,
224 lastCheckStart: undefined as number | undefined,
225 /** The next prompt gets the context line even when unchanged: a new conversation, or a compacted one. */
226 isContextForced: true,
227}
228
229/**
230 * One check run at a time. `isRunning`: the engine process has started, so a request now is owed one run
231 * after it; a request before that folds into the run, which reads the files afresh anyway.
232 */
233const pacing = { isBusy: false, isRunning: false, isOwed: false }
234
235// ---------------------------------------------------------------------------
236// Host access
237
238async function readText($: Dollar, path: string): Promise<string | undefined> {
239 const raw = await $.fs.read(path).catch(noop)
240 return typeof raw === 'string' ? raw : undefined
241}
242
243async function mtimeOf($: Dollar, path: string): Promise<number | undefined> {
244 const st = await $.fs.stat(path).catch(noop)
245 return st === undefined ? undefined : st.mtimeMs
246}
247
248/**
249 * Shows the start notice as `todo.notice.style` says: the band, kept until Dismiss with its view live, or one
250 * toast of the view as it stands for `todo.notice.toast_seconds` (a click takes it off sooner, the pointer
251 * over it holds it). An empty view shows nothing. Never throws.
252 */
253async function showNotice($: Dollar, cfg: TodoWatchConfig, view: NoticeView): Promise<void> {
254 if (isEmptyView(view)) return
255 if (cfg.noticeStyle === 'banner') return showBand($)
256 try {
257 void Promise.resolve($.ui.toast(noticeText(view), { timeoutMs: cfg.toastMs })).catch(noop)
258 } catch {
259 // a surface that draws no toast
260 }
261}
262
263/**
264 * Recomputes the notice view from the summary, the fresh report and the checks status, and stores it: a
265 * shown band follows it; nothing is shown again. Never throws.
266 */
267async function refreshView($: Dollar, w: Watch): Promise<NoticeView> {
268 const summary = await read($, summaryAtom).catch(() => null)
269 const report = await read($, reportAtom).catch(() => null)
270 const view = formatNotice(summary, report, session.status, w.cfg.noticeItems)
271 await setNoticeView($, view)
272 return view
273}
274
275/** Runs `fn` after the current dispatch, so no tool result or prompt waits on it. */
276function later($: Dollar, fn: () => Promise<unknown>): void {
277 try {
278 $.clock.after(0, () => {
279 void fn().catch(noop)
280 })
281 } catch {
282 // no timer: this pass is skipped
283 }
284}
285
286const runnerPathOf = (w: Watch): string => `${under(w.root, syncDirOf(w.cfg))}/runner.json`
287const reportPathOf = (w: Watch): string =>
288 session.runner?.report ?? `${under(w.root, syncDirOf(w.cfg))}/report.json`
289
290/** The runner pointer, when it reads and its script exists; undefined otherwise. */
291async function lookupRunner($: Dollar, w: Watch): Promise<Runner | undefined> {
292 const text = await readText($, runnerPathOf(w))
293 const runner = text === undefined ? undefined : parseRunner(text)
294 if (runner === undefined) return undefined
295 return (await $.fs.exists(runner.script).catch(() => false)) ? runner : undefined
296}
297
298// ---------------------------------------------------------------------------
299// Catalog and report
300
301/**
302 * Re-reads the catalog when its mtime moved, keeping enough rows for `/todo` and the notice; an unreadable
303 * or malformed one clears the summary. A re-read recomputes the notice view.
304 */
305async function refreshCatalog($: Dollar, w: Watch): Promise<TodoSummary | null> {
306 const path = under(w.root, w.cfg.catalog ?? '')
307 const mtime = await mtimeOf($, path)
308 if (mtime !== undefined && mtime === session.catalogMtime) return read($, summaryAtom)
309 session.catalogMtime = mtime
310 const text = mtime === undefined ? undefined : await readText($, path)
311 const rows = Math.max(w.cfg.topN, w.cfg.noticeItems)
312 const summary = text === undefined ? null : (parseCatalog(text, rows, mtime ?? 0) ?? null)
313 await update($, summaryAtom, () => summary)
314 await refreshView($, w)
315 return summary
316}
317
318/** Stores a fresh report and recomputes the notice view; shows nothing. */
319async function applyReport($: Dollar, w: Watch, report: SyncReport): Promise<void> {
320 await update($, reportAtom, () => report)
321 await refreshView($, w)
322}
323
324/**
325 * The session start floored to the whole second: the earliest `generated_at` a fresh report may carry, as
326 * an engine that writes whole seconds stamps a check finishing in the start's own second before the start.
327 */
328export const startGateMs = (startedAtMs: number): number => Math.floor(startedAtMs / 1000) * 1000
329
330/**
331 * Re-reads the report when its mtime moved (always after a check run of the mod's own, `isForced`, as a
332 * coarse file clock may not move between two runs); one older than the session start's second is ignored.
333 */
334async function refreshReport($: Dollar, w: Watch, isForced = false): Promise<void> {
335 const path = reportPathOf(w)
336 const mtime = await mtimeOf($, path)
337 if (mtime === undefined || (mtime === session.reportMtime && !isForced)) return
338 session.reportMtime = mtime
339 const text = await readText($, path)
340 const report = text === undefined ? undefined : parseReport(text, mtime)
341 if (report === undefined || reportTimeMs(report) < startGateMs(session.startedAtMs ?? 0)) return
342 await applyReport($, w, report)
343}
344
345// ---------------------------------------------------------------------------
346// Check runs: one in flight, an owed rerun
347
348/** Runs `todo_sync.py check`; true on exit 0, false on any failure (refusal, timeout, non-zero exit). */
349async function runEngine($: Dollar, r: Runner): Promise<boolean> {
350 const argv = [r.python, r.script, 'check', '--project', r.project]
351 return $.process.run(argv, { timeoutMs: CHECK_TIMEOUT_MS }).then(ran => ran.exitCode === 0, () => false)
352}
353
354/** One check: the pointer looked up again (it may have gone stale), the run, then the re-reads. */
355async function runCycle($: Dollar): Promise<void> {
356 const w = await resolveWatch($)
357 const runner = w === undefined ? undefined : await lookupRunner($, w)
358 if (w === undefined || runner === undefined) return
359 session.runner = runner
360 session.status = 'found'
361 await refreshView($, w)
362 session.lastCheckStart = await $.clock.now()
363 pacing.isRunning = true
364 const isOk = await runEngine($, runner)
365 await refreshCatalog($, w).catch(noop)
366 if (isOk) await refreshReport($, w, true)
367}
368
369/** Asks for a check run and returns at once; folds into a run already scheduled or in flight. */
370function requestCheck($: Dollar): void {
371 if (pacing.isBusy) {
372 if (pacing.isRunning) pacing.isOwed = true
373 return
374 }
375 pacing.isBusy = true
376 void runCycle($)
377 .catch(noop)
378 .finally(() => finishCycle($))
379}
380
381/** Ends a run and starts the one owed, if any. */
382function finishCycle($: Dollar): void {
383 pacing.isBusy = false
384 pacing.isRunning = false
385 if (!pacing.isOwed) return
386 pacing.isOwed = false
387 requestCheck($)
388}
389
390/** The newest mtime among the entries of `dir`, and of its subfolders' entries `depth` levels further down. */
391async function newestListed($: Dollar, dir: string, depth: number): Promise<number> {
392 let newest = Number.NEGATIVE_INFINITY
393 for (const entry of await $.fs.list(dir).catch(() => [])) {
394 newest = Math.max(newest, entry.mtimeMs)
395 if (depth > 0 && entry.kind === 'dir') {
396 newest = Math.max(newest, await newestListed($, `${dir}/${entry.name}`, depth - 1))
397 }
398 }
399 return newest
400}
401
402/**
403 * The newest mtime among the watched files, the watched folders and their top-level entries; under the
404 * research folder one level deeper too, where the `<DOMAIN>/<stub>` files live, so an in-place stub edit
405 * outside the session trips the probe.
406 */
407async function newestWatchedMtime($: Dollar, w: Watch): Promise<number> {
408 let newest = Number.NEGATIVE_INFINITY
409 for (const path of [...watchedFiles(w), ...watchedDirs(w)]) {
410 newest = Math.max(newest, (await mtimeOf($, path)) ?? newest)
411 }
412 const research = under(w.root, w.cfg.researchDir)
413 for (const dir of watchedDirs(w)) {
414 newest = Math.max(newest, await newestListed($, dir, dir === research ? 1 : 0))
415 }
416 return newest
417}
418
419/** Schedules a check when a watched mtime is newer than the last check start (else the last report). */
420async function probeWatched($: Dollar, w: Watch): Promise<void> {
421 if (session.status !== 'found') return
422 const basis = session.lastCheckStart ?? session.reportMtime ?? Number.NEGATIVE_INFINITY
423 if ((await newestWatchedMtime($, w)) > basis) requestCheck($)
424}
425
426// ---------------------------------------------------------------------------
427// Session start and the runner retry
428
429/** Forgets the previous start's report and band; the start time is now. */
430async function resetSession($: Dollar, w: Watch): Promise<void> {
431 session.startedAtMs = await $.clock.now()
432 session.status = 'pending'
433 session.lookup = undefined
434 session.runner = undefined
435 session.catalogMtime = undefined
436 session.reportMtime = undefined
437 session.lastCheckStart = undefined
438 session.isContextForced = true
439 await update($, reportAtom, () => null).catch(noop)
440 await resetBand($)
441 await refreshView($, w)
442}
443
444/**
445 * `session.start` (wired in ./register): the start notice (items, no status before a fresh report), then the
446 * runner lookup and the first check. Never throws.
447 */
448export async function startSession($: Dollar): Promise<void> {
449 await startWatch($).catch(noop)
450}
451
452async function startWatch($: Dollar): Promise<void> {
453 const w = await resolveWatch($)
454 if (w === undefined) return
455 await resetSession($, w)
456 await refreshCatalog($, w).catch(noop)
457 await showNotice($, w.cfg, await refreshView($, w))
458 const runner = await lookupRunner($, w).catch(noop)
459 if (runner === undefined) return
460 session.runner = runner
461 session.status = 'found'
462 await refreshView($, w)
463 later($, async () => requestCheck($))
464}
465
466/** The one retry of a runner pointer missing at `session.start` (single-flight): found, else unavailable. */
467async function retryLookup($: Dollar, w: Watch): Promise<void> {
468 if (session.status !== 'pending') return
469 if (session.startedAtMs === undefined) session.startedAtMs = await $.clock.now()
470 const isFirst = session.lookup === undefined
471 session.lookup ??= lookupRunner($, w).catch(noop)
472 const runner = await session.lookup
473 if (!isFirst || session.status !== 'pending') return
474 session.runner = runner
475 session.status = runner === undefined ? 'unavailable' : 'found'
476 await refreshView($, w)
477 if (runner !== undefined) later($, async () => requestCheck($))
478}
479
480/** Prompt and turn triggers (M3b): the retry, then, after the dispatch, the catalog and the mtime probe. */
481async function onTrigger($: Dollar, w: Watch): Promise<void> {
482 await retryLookup($, w)
483 later($, async () => {
484 await refreshCatalog($, w).catch(noop)
485 await probeWatched($, w)
486 })
487}
488
489// ---------------------------------------------------------------------------
490// Prompt context
491
492/** This prompt's TODO_SYNC line when it should be attached; records nothing. */
493async function promptLine($: Dollar): Promise<string | undefined> {
494 const w = await resolveWatch($)
495 if (w === undefined) return undefined
496 await onTrigger($, w)
497 await refreshReport($, w).catch(noop)
498 if (!w.cfg.injectContext) return undefined
499 const line = contextLineFor(await read($, reportAtom), session.status)
500 if (line === undefined || session.isContextForced) return line
501 return (await read($, lastContextAtom).catch(() => '')) === line ? undefined : line
502}
503
504/** Records `line` as attached, once the prompt entered with it and the dispatch did not move on. */
505async function recordAttached($: Dollar, line: string, result: PromptSubmitResult, signal: AbortSignal) {
506 if (signal.aborted || result.drop !== undefined || !(result.context ?? []).includes(line)) return
507 session.isContextForced = false
508 await update($, lastContextAtom, () => line).catch(noop)
509}
510
511/** Session-end reasons after which the same process goes on with a fresh conversation. */
512const FRESH_CONVERSATION = new Set<string>(['clear', 'resume'])
513
514/** Whether a compaction result replaced the main conversation (its TODO_SYNC line may be gone). */
515const isMainCompaction = (e: SessionCompactInput, result: SessionCompactResult): boolean =>
516 result.skip === undefined && e.agentId === undefined && e.trigger !== 'precompute'
517
518// ---------------------------------------------------------------------------
519// The /todo body
520
521/** The `/todo` text for the session's project, catalog and report re-read when moved; undefined inactive. */
522export async function todoText($: Dollar): Promise<string | undefined> {
523 const w = await resolveWatch($)
524 if (w === undefined) return undefined
525 const summary = await refreshCatalog($, w).catch(() => null)
526 await refreshReport($, w).catch(noop)
527 const report = await read($, reportAtom)
528 const now = await $.clock.now()
529 const ghAge = report === null ? undefined : ghAgeText(report.ghFetchedAt, now, w.cfg.ghMaxAgeHours)
530 const sync = report === null ? {} : { drift: report.drift, ghAge }
531 return formatTodoBody(summary, report, session.status, sync, w.cfg.topN)
532}
533
534// ---------------------------------------------------------------------------
535// Tool calls (M3)
536
537type CallSeen = { tool: string; path: string | undefined; isRefused: boolean }
538
539/** After a watching tool's call: a file tool on a watched path checks; a shell call probes mtimes. */
540async function afterToolCall($: Dollar, call: CallSeen): Promise<void> {
541 const w = await resolveWatch($)
542 if (w === undefined) return
543 if (SHELL_TOOLS.test(call.tool)) return probeWatched($, w)
544 if (!call.isRefused && call.path !== undefined && isWatchedPath(w, call.path)) requestCheck($)
545}
546
547// ---------------------------------------------------------------------------
548// Registration
549
550/** Tool and turn hooks: gated check runs and the M3b mtime probe. */
551export const registerChecks = (on: On): void => {
552 on('tool.call', { tool: WATCH_TOOLS }, async ($, e, next) => {
553 const result = await next(e)
554 const isRefused = result.deny !== undefined || result.isError === true
555 const call: CallSeen = { tool: String(e.tool), path: pathOfCall(e), isRefused }
556 later($, () => afterToolCall($, call))
557 return result
558 }).catch((_$, e, next) => next(e))
559
560 on('turn.complete', async ($, e, next) => {
561 const result = await next(e)
562 if (e.agentId !== undefined) return result
563 const w = await resolveWatch($).catch(noop)
564 if (w !== undefined) await onTrigger($, w).catch(noop)
565 return result
566 })
567}
568
569/** The TODO_SYNC line on prompts (M2) and its re-force after `/clear`, a resume or a main compaction. */
570export const registerContext = (on: On): void => {
571 on('prompt.submit', async ($, e, next) => {
572 const line = await promptLine($).catch(noop)
573 if (line === undefined || next.signal.aborted) return next(e)
574 const result = await next({ ...e, context: [...(e.context ?? []), line] })
575 await recordAttached($, line, result, next.signal).catch(noop)
576 return result
577 }).catch((_$, e, next) => next(e))
578
579 on('session.end', async ($, e, next) => {
580 if (FRESH_CONVERSATION.has(e.reason)) session.isContextForced = true
581 return next(e)
582 }).catch((_$, e, next) => next(e))
583
584 on('session.compact', async ($, e, next) => {
585 const result = await next(e)
586 if (isMainCompaction(e, result)) session.isContextForced = true
587 return result
588 }).catch((_$, e, next) => next(e))
589}
590hooks/command.ts 43 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { todoText } from './checks'
4import { resolveWatch } from './watch'
5
6/**
7 * The `/todo` command (M5): registered at `session.start` only in an active project; prints the catalog
8 * summary, the top-N table and the sync findings. Outside an active project the command is not registered
9 * and a `/todo` of another source passes through.
10 */
11
12export const COMMAND = 'todo'
13export const COMMAND_DESCRIPTION = 'Show the TODO catalog summary, its top items and the sync findings'
14export const UNAVAILABLE_COMMAND_TEXT = 'todo-watch: TODO state unavailable.'
15
16const noop = (): undefined => undefined
17
18/** Whether this load registered the command. */
19const command = { isRegistered: false }
20
21/**
22 * Registers `/todo` once per load when the watch is active (wired into `session.start` by ./register).
23 * Never throws. A function declaration: the engine follows `$` past an import only into one.
24 */
25export async function registerTodoCommand($: EngineInterface): Promise<void> {
26 const w = await resolveWatch($).catch(noop)
27 if (w === undefined || command.isRegistered) return
28 await $.command
29 .register({ name: COMMAND, description: COMMAND_DESCRIPTION })
30 .then(() => {
31 command.isRegistered = true
32 })
33 .catch(noop)
34}
35
36/** Answers `/todo` while registered; passes it on otherwise. */
37export const registerCommand = (on: On): void => {
38 on('command.run', { command: COMMAND }, async ($, e, next) => {
39 if (!command.isRegistered) return next(e)
40 return { text: (await todoText($)) ?? UNAVAILABLE_COMMAND_TEXT }
41 }).catch((_$, e, next) => (command.isRegistered ? { text: UNAVAILABLE_COMMAND_TEXT } : next(e)))
42}
43hooks/summary.ts 236 lines1/**
2 * Pure text work for todo-watch: the TODO catalog's counts and top-N, the notice view, the summary line, the
3 * TODO_SYNC context line and the `/todo` body. No `$`.
4 */
5
6import type { NoticeView, SyncFinding, SyncReport, TodoRow, TodoSummary } from '../types'
7
8type Json = Readonly<Record<string, unknown>>
9
10const SEP = ' · '
11const HOUR_MS = 3_600_000
12const MAX_TOP_TITLE = 60
13export const UNAVAILABLE_CONTEXT = 'TODO_SYNC unavailable'
14export const NO_REPORT_TEXT = 'no report yet'
15export const UNAVAILABLE_TEXT = 'sync checks unavailable'
16export const CLEAN_TEXT = 'sync clean'
17export const IN_SYNC_TEXT = '✓ in sync'
18/** The notice's item titles are folded to one line and clipped to this many characters. */
19export const MAX_NOTICE_TITLE = 80
20/** Joins the toast's lines: the item lines, then the status. */
21export const NOTICE_TOAST_SEP = '\n'
22
23const isObject = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
24const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0
25const countAt = (o: Json, key: string): number => (isCount(o[key]) ? (o[key] as number) : 0)
26
27const parseJson = (text: string): unknown => {
28 try {
29 return JSON.parse(text)
30 } catch {
31 return undefined
32 }
33}
34
35// ---------------------------------------------------------------------------
36// Catalog
37
38/** One item as a row; undefined when its id, rank, title or status is missing. */
39const rowOf = (item: unknown): TodoRow | undefined => {
40 if (!isObject(item)) return undefined
41 const { id, rank, title, status } = item
42 if (typeof id !== 'string' || typeof rank !== 'number' || typeof title !== 'string') return undefined
43 if (typeof status !== 'string') return undefined
44 const score = isObject(item.priority) ? item.priority.score : undefined
45 const isScore = typeof score === 'number' && Number.isFinite(score)
46 return { id, rank, title, status, score: isScore ? score : null }
47}
48
49/** Items ordered by rank, at most `topN`. */
50const topRows = (items: readonly unknown[], topN: number): TodoRow[] =>
51 items
52 .map(rowOf)
53 .filter((r): r is TodoRow => r !== undefined)
54 .sort((a, b) => a.rank - b.rank)
55 .slice(0, topN)
56
57/**
58 * The summary of a TODO catalog text (TodoCatalog shape: `counts.items`, `counts.by_status`,
59 * `counts.needs_decision`, `items[]`); undefined when malformed.
60 */
61export const parseCatalog = (text: string, topN: number, mtimeMs: number): TodoSummary | undefined => {
62 const data = parseJson(text)
63 if (!isObject(data) || !isObject(data.counts) || !Array.isArray(data.items)) return undefined
64 const counts = data.counts
65 if (!isCount(counts.items)) return undefined
66 const byStatus = isObject(counts.by_status) ? counts.by_status : {}
67 return {
68 items: counts.items,
69 open: countAt(byStatus, 'OPEN'),
70 partial: countAt(byStatus, 'PARTIAL'),
71 blocked: countAt(byStatus, 'BLOCKED'),
72 needsDecision: countAt(counts, 'needs_decision'),
73 top: topRows(data.items, topN),
74 mtimeMs,
75 }
76}
77
78// ---------------------------------------------------------------------------
79// Summary line
80
81/** What the summary line says about sync: a fresh report's drift and the issue cache's age. */
82export type SyncSuffix = { drift?: number; ghAge?: string }
83
84/** `3d` or `8h` when `fetchedAt` is older than `maxAgeHours`; undefined when fresh, null or unparsable. */
85export const ghAgeText = (
86 fetchedAt: string | null,
87 nowMs: number,
88 maxAgeHours: number,
89): string | undefined => {
90 if (fetchedAt === null) return undefined
91 const at = Date.parse(fetchedAt)
92 if (!Number.isFinite(at)) return undefined
93 const hours = (nowMs - at) / HOUR_MS
94 if (hours <= maxAgeHours) return undefined
95 return hours < 24 ? `${Math.floor(hours)}h` : `${Math.floor(hours / 24)}d`
96}
97
98const countSegments = (s: TodoSummary): string[] => {
99 const parts: Array<[number, string]> = [
100 [s.open, 'open'], [s.partial, 'partial'], [s.blocked, 'blocked'], [s.needsDecision, 'need a decision'],
101 ]
102 return parts.filter(([n]) => n > 0).map(([n, label]) => `${n} ${label}`)
103}
104
105const syncSegments = (sync: SyncSuffix): string[] => {
106 const out: string[] = []
107 if (sync.drift !== undefined) out.push(sync.drift === 0 ? CLEAN_TEXT : `${sync.drift} out of sync`)
108 if (sync.ghAge !== undefined) out.push(`gh ${sync.ghAge} old`)
109 return out
110}
111
112/** The rank-1 title on one line, clipped so the sync parts after it stay visible. */
113const topTitle = (title: string): string => clip(title.replace(/\s+/g, ' ').trim(), MAX_TOP_TITLE)
114
115/** `TODO 89 · 68 open · 12 partial · 1 blocked · 12 need a decision · top: <title>`, then the sync parts. */
116export const formatSummary = (s: TodoSummary, sync: SyncSuffix = {}): string => {
117 const top = s.top[0]
118 const parts = [`TODO ${s.items}`, ...countSegments(s), ...(top === undefined ? [] : [`top: ${topTitle(top.title)}`])]
119 return [...parts, ...syncSegments(sync)].join(SEP)
120}
121
122// ---------------------------------------------------------------------------
123// The notice
124
125/** `<rank> <title>`, the title folded to one line and clipped to `MAX_NOTICE_TITLE`. */
126const itemLine = (r: TodoRow): string => `${r.rank} ${clip(r.title.replace(/\s+/g, ' ').trim(), MAX_NOTICE_TITLE)}`
127
128/**
129 * The status line: a fresh report wins (`✓ in sync` or `<N> out of sync`); without one, `sync checks
130 * unavailable` once checks are known to be unavailable, else none.
131 */
132const noticeStatus = (report: Pick<SyncReport, 'drift'> | null, status: SyncStatus): NoticeView['status'] => {
133 if (report === null) return status === 'unavailable' ? { text: UNAVAILABLE_TEXT, tone: 'dim' } : null
134 if (report.drift === 0) return { text: IN_SYNC_TEXT, tone: 'ok' }
135 return { text: `${report.drift} out of sync`, tone: 'warn' }
136}
137
138/** The notice: the first `count` summary rows by rank, then the sync status. */
139export const formatNotice = (
140 summary: TodoSummary | null,
141 report: Pick<SyncReport, 'drift'> | null,
142 status: SyncStatus,
143 count: number,
144): NoticeView => ({
145 items: summary === null ? [] : summary.top.slice(0, count).map(itemLine),
146 status: noticeStatus(report, status),
147})
148
149/** The toast text: the item lines, then the status text, joined by `NOTICE_TOAST_SEP`; never clipped whole. */
150export const noticeText = (view: NoticeView): string =>
151 [...view.items, ...(view.status === null ? [] : [view.status.text])].join(NOTICE_TOAST_SEP)
152
153/** Whether the view has nothing to show: no item lines and no status. */
154export const isEmptyView = (view: NoticeView): boolean => view.items.length === 0 && view.status === null
155
156// ---------------------------------------------------------------------------
157// The context line
158
159/** Rule id order: the letter, then the number (`G2`, `P1`, `S5`, `S10`, `T2`). */
160const byRuleId = (a: string, b: string): number => {
161 const letters = a.replace(/\d+$/, '').localeCompare(b.replace(/\d+$/, ''))
162 return letters !== 0 ? letters : Number(a.replace(/^\D+/, '')) - Number(b.replace(/^\D+/, ''))
163}
164
165/** `P1=1 S5=1 T2=1`, rules sorted by id. */
166export const ruleCounts = (byRule: Readonly<Record<string, number>>): string =>
167 Object.keys(byRule)
168 .sort(byRuleId)
169 .map(rule => `${rule}=${byRule[rule]}`)
170 .join(' ')
171
172/** The prompt context line: `TODO_SYNC drift=4 P1=1 S5=1 T2=1`. */
173export const formatContextLine = (r: Pick<SyncReport, 'drift' | 'byRule'>): string => {
174 const rules = ruleCounts(r.byRule)
175 return rules === '' ? `TODO_SYNC drift=${r.drift}` : `TODO_SYNC drift=${r.drift} ${rules}`
176}
177
178/**
179 * The line a prompt should carry: a fresh report's drift, `TODO_SYNC unavailable` once checks are known to
180 * be unavailable, else none (before any fresh report, and while the runner lookup is pending).
181 */
182export const contextLineFor = (report: SyncReport | null, status: SyncStatus): string | undefined => {
183 if (report !== null) return formatContextLine(report)
184 return status === 'unavailable' ? UNAVAILABLE_CONTEXT : undefined
185}
186
187const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
188
189// ---------------------------------------------------------------------------
190// The /todo body
191
192const cell = (text: string): string => text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
193const scoreText = (score: number | null): string => (score === null ? '-' : String(Math.round(score * 100) / 100))
194
195/** The top-N rows as a Markdown table; empty when there are none. */
196export const formatTable = (rows: readonly TodoRow[]): string => {
197 if (rows.length === 0) return ''
198 const head = ['| Rank | Id | Status | Score | Title |', '|---:|---|---|---:|---|']
199 const body = rows.map(r => `| ${r.rank} | ${r.id} | ${r.status} | ${scoreText(r.score)} | ${cell(r.title)} |`)
200 return [...head, ...body].join('\n')
201}
202
203/** `rule severity file:line subject: message (fix)`, null parts left out. */
204export const formatFinding = (f: SyncFinding): string => {
205 const where = f.file === null ? '' : f.line === null ? ` ${f.file}` : ` ${f.file}:${f.line}`
206 const subject = f.subject === null ? '' : ` ${f.subject}`
207 const fix = f.fix === null ? '' : ` (${f.fix})`
208 return `${f.rule} ${f.severity}${where}${subject}: ${f.message}${fix}`
209}
210
211/** Whether sync checks have a runner: still looking, found, or not after the retry. */
212export type SyncStatus = 'pending' | 'found' | 'unavailable'
213
214/** The sync part of `/todo`: the findings, `sync clean`, `sync checks unavailable` or `no report yet`. */
215export const formatSyncSection = (report: SyncReport | null, status: SyncStatus): string => {
216 if (report === null) return status === 'unavailable' ? UNAVAILABLE_TEXT : NO_REPORT_TEXT
217 const head = report.drift === 0 ? CLEAN_TEXT : `sync drift ${report.drift}: ${ruleCounts(report.byRule)}`
218 return [head, ...report.findings.map(formatFinding)].join('\n')
219}
220
221/**
222 * The whole `/todo` body: summary line, the table of the first `topN` rows (every parsed row when not given),
223 * sync section.
224 */
225export const formatTodoBody = (
226 summary: TodoSummary | null,
227 report: SyncReport | null,
228 status: SyncStatus,
229 sync: SyncSuffix,
230 topN?: number,
231): string => {
232 const head = summary === null ? 'TODO catalog unreadable' : formatSummary(summary, sync)
233 const table = summary === null ? '' : formatTable(summary.top.slice(0, topN))
234 return [head, table, formatSyncSection(report, status)].filter(part => part !== '').join('\n\n')
235}
236hooks/config.ts 180 lines1/**
2 * The harness keys the watch reads from `<project>/.claude/harness.json`, merged over copies of the harness
3 * plugin's `config/defaults.json` values: an installed mod sits in its own plugin cache folder and cannot
4 * read that file. `tests/mods/test_todo_watch_config_mirror.py` pins the copies. Pure: no `$`.
5 */
6
7export const DEFAULT_STATE_DIR = '.claude/state/harness'
8export const DEFAULT_RESEARCH = 'docs/RESEARCH'
9export const DEFAULT_PLANS = 'docs/PLANS'
10export const DEFAULT_FOLDER: string | null = null
11export const DEFAULT_CATALOG = 'RepoToDo.json'
12export const DEFAULT_CATALOG_SCHEMA: string | null = 'RepoToDo.schema.json'
13export const DEFAULT_TOP_N = 10
14export const DEFAULT_SNAPSHOTS_DIR: string | null = 'Snapshots'
15export const DEFAULT_RESEARCH_CATALOG: string | null = 'ResearchImplementationCatalog.json'
16export const DEFAULT_GH_MAX_AGE_HOURS = 24
17export const DEFAULT_INJECT_CONTEXT = true
18export const DEFAULT_NOTICE_STYLE: NoticeStyle = 'banner'
19export const DEFAULT_TOAST_SECONDS = 60
20export const DEFAULT_NOTICE_ITEMS = 3
21
22/** How a notice shows: a toast that leaves on a timer, or a band line kept until dismissed. */
23export type NoticeStyle = 'toast' | 'banner'
24const NOTICE_STYLES: readonly NoticeStyle[] = ['toast', 'banner']
25/** The engine's toast ceiling: its host check refuses a `timeoutMs` past 60000, and a toast has no sticky flag. */
26export const MAX_TOAST_SECONDS = 60
27/** The most top items the notice lists; the harness schema caps `todo.notice.items` at the same value. */
28export const MAX_NOTICE_ITEMS = 10
29
30/**
31 * Project-relative POSIX paths and the TODO settings the watch reads; an absolute path is rejected by the
32 * harness schema (`relPath`), and a rooted `todo.folder` leaves the watch inactive. The TODO paths are
33 * resolved under `todo.folder` as harness_lib does; each is null while the folder is unset or rooted, its key
34 * is null, or it climbs above the project root.
35 */
36export type TodoWatchConfig = {
37 stateDir: string
38 researchDir: string
39 plansDir: string
40 folder: string | null
41 catalog: string | null
42 catalogSchema: string | null
43 topN: number
44 snapshotsDir: string | null
45 researchCatalog: string | null
46 ghMaxAgeHours: number
47 injectContext: boolean
48 noticeStyle: NoticeStyle
49 /** How long a toast stays, in milliseconds. */
50 toastMs: number
51 /** How many top items, by rank, the notice lists. */
52 noticeItems: number
53}
54
55type Json = Readonly<Record<string, unknown>>
56
57const isObject = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
58
59/** Forward slashes, no surrounding blanks, no trailing slash. */
60export const cleanPath = (p: string): string => p.trim().replace(/\\/g, '/').replace(/\/+$/, '')
61
62/** A non-blank string value, cleaned; else the fallback. */
63const pathOr = (v: unknown, fallback: string): string =>
64 typeof v === 'string' && cleanPath(v) !== '' ? cleanPath(v) : fallback
65
66/** A non-blank string value, cleaned; null when the value is null; else the fallback. */
67const optionalPathOr = (v: unknown, fallback: string | null): string | null => {
68 if (v === null) return null
69 return typeof v === 'string' && cleanPath(v) !== '' ? cleanPath(v) : fallback
70}
71
72/** A positive whole number; else the fallback. */
73const countOr = (v: unknown, fallback: number): number =>
74 typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : fallback
75
76/** A finite number at or above zero; else the fallback. */
77const hoursOr = (v: unknown, fallback: number): number =>
78 typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback
79
80/** One of `NOTICE_STYLES`; else the fallback. */
81const styleOr = (v: unknown, fallback: NoticeStyle): NoticeStyle =>
82 NOTICE_STYLES.find(s => s === v) ?? fallback
83
84/** Seconds above zero and at most `MAX_TOAST_SECONDS`; else the fallback. */
85const toastSecondsOr = (v: unknown, fallback: number): number =>
86 typeof v === 'number' && v > 0 && v <= MAX_TOAST_SECONDS ? v : fallback
87
88/** A whole number from 1 to `MAX_NOTICE_ITEMS`; else the fallback. */
89const itemsOr = (v: unknown, fallback: number): number =>
90 typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= MAX_NOTICE_ITEMS ? v : fallback
91
92const objectAt = (data: Json, key: string): Json => (isObject(data[key]) ? (data[key] as Json) : {})
93
94/**
95 * `name` joined to `folder` with `.` and `..` segments folded, as Python's `posixpath.normpath`; null when
96 * either is null.
97 */
98export const inFolder = (folder: string | null, name: string | null): string | null => {
99 if (folder === null || name === null) return null
100 const joined = `${folder}/${cleanPath(name)}`
101 const out: string[] = []
102 for (const seg of joined.split('/')) {
103 if (seg === '' || seg === '.') continue
104 if (seg === '..' && out.length > 0 && out[out.length - 1] !== '..') out.pop()
105 else out.push(seg)
106 }
107 const body = out.join('/') || '.'
108 return joined.startsWith('/') ? `/${body}` : body
109}
110
111/**
112 * A resolved TODO path, or null when it climbs above the project root (`..` or `../...`): harness_lib refuses
113 * such a path at config load, so the watch treats it as unset.
114 */
115export const insideRoot = (path: string | null): string | null =>
116 path === null || path === '..' || path.startsWith('../') ? null : path
117
118/**
119 * Whether a cleaned path is rooted or drive-prefixed (`/x`, `C:/x`, `C:x`): the harness schema rejects such a
120 * `todo.folder`, so the watch stays inactive rather than reading files outside the project.
121 */
122const isRooted = (path: string): boolean => /^(\/|[A-Za-z]:)/.test(path)
123
124/** `inFolder` with the project-root check applied to the result. */
125const todoPath = (folder: string | null, name: string | null): string | null => insideRoot(inFolder(folder, name))
126
127const parse = (text: string): Json => {
128 try {
129 const data: unknown = JSON.parse(text)
130 return isObject(data) ? data : {}
131 } catch {
132 return {}
133 }
134}
135
136/**
137 * The watch's view of a `harness.json` text: each key it reads replaces its default, objects merge key by
138 * key. Malformed JSON or a wrong-typed value keeps the default (the harness hooks report it).
139 */
140export const readConfig = (text: string): TodoWatchConfig => {
141 const data = parse(text)
142 const artifacts = objectAt(data, 'artifacts')
143 const todo = objectAt(data, 'todo')
144 const notice = objectAt(todo, 'notice')
145 const inject = todo.inject_context
146 const named = optionalPathOr(todo.folder, DEFAULT_FOLDER)
147 const folder = named !== null && !isRooted(named) && insideRoot(inFolder(named, '.')) !== null ? named : null
148 return {
149 stateDir: pathOr(data.state_dir, DEFAULT_STATE_DIR),
150 researchDir: pathOr(artifacts.research, DEFAULT_RESEARCH),
151 plansDir: pathOr(artifacts.plans, DEFAULT_PLANS),
152 folder,
153 catalog: todoPath(folder, pathOr(todo.catalog, DEFAULT_CATALOG)),
154 catalogSchema: todoPath(folder, optionalPathOr(todo.catalog_schema, DEFAULT_CATALOG_SCHEMA)),
155 topN: countOr(todo.top_n, DEFAULT_TOP_N),
156 snapshotsDir: todoPath(folder, optionalPathOr(objectAt(todo, 'snapshots').dir, DEFAULT_SNAPSHOTS_DIR)),
157 researchCatalog: todoPath(folder, optionalPathOr(objectAt(todo, 'research').catalog, DEFAULT_RESEARCH_CATALOG)),
158 ghMaxAgeHours: hoursOr(objectAt(todo, 'gh').max_age_hours, DEFAULT_GH_MAX_AGE_HOURS),
159 injectContext: typeof inject === 'boolean' ? inject : DEFAULT_INJECT_CONTEXT,
160 noticeStyle: styleOr(notice.style, DEFAULT_NOTICE_STYLE),
161 toastMs: Math.max(1, Math.round(toastSecondsOr(notice.toast_seconds, DEFAULT_TOAST_SECONDS) * 1000)),
162 noticeItems: itemsOr(notice.items, DEFAULT_NOTICE_ITEMS),
163 }
164}
165
166/** Whether the watch runs at all: `todo.folder` is configured (the catalog path follows from it). */
167export const isActive = (c: TodoWatchConfig): boolean => c.catalog !== null
168
169/** Whether a path is absolute: POSIX, drive or UNC. */
170export const isAbsolute = (path: string): boolean => /^([A-Za-z]:)?[\\/]/.test(path)
171
172/**
173 * A configured path against the project root: a relative one is joined to it, an absolute one is kept, as
174 * harness_lib's `cfg.path()` does.
175 */
176export const under = (root: string, path: string): string => (isAbsolute(path) ? path : `${root}/${path}`)
177
178/** `<state_dir>/todo-sync`: the runner pointer and the sync report live here. */
179export const syncDirOf = (c: TodoWatchConfig): string => `${c.stateDir}/todo-sync`
180hooks/watch.ts 50 lines1import type { EngineInterface } from 'claude-code'
2
3import { isActive, readConfig, type TodoWatchConfig } from './config'
4import { configPathOf, rootCandidates } from './root'
5
6/** The project the watch serves: its root (forward slashes) and its harness configuration. */
7export type Watch = { root: string; cfg: TodoWatchConfig }
8
9const noop = (): undefined => undefined
10
11/** Roots already found (null: none holds the file), by cwd and CLAUDE_PROJECT_DIR; a reload starts afresh. */
12const roots = new Map<string, string | null>()
13
14/** The first candidate (./root) whose `.claude/harness.json` reads, with its text; else undefined. */
15async function findRoot($: EngineInterface, env: string | undefined, cwd: string | undefined) {
16 for (const root of rootCandidates(env, cwd)) {
17 const text = await $.fs.read(configPathOf(root)).catch(noop)
18 if (typeof text === 'string') return { root, text }
19 }
20 return undefined
21}
22
23/** The root and its harness.json text, the root remembered per cwd; the file is read afresh each time. */
24async function rootAndConfig($: EngineInterface): Promise<{ root: string; text: string } | undefined> {
25 const env = await $.env.get('CLAUDE_PROJECT_DIR').catch(noop)
26 const cwd = await $.session.cwd().catch(noop)
27 const key = `${cwd ?? ''}\n${env ?? ''}`
28 const known = roots.get(key)
29 if (known === null) return undefined
30 if (known !== undefined) {
31 const text = await $.fs.read(configPathOf(known)).catch(noop)
32 return typeof text === 'string' ? { root: known, text } : undefined
33 }
34 const found = await findRoot($, env, cwd)
35 roots.set(key, found?.root ?? null)
36 return found
37}
38
39/**
40 * The watch for the session's project: active only when `.claude/harness.json` reads and `todo.folder` is
41 * a non-blank string; undefined otherwise. Never throws, never spawns a process. A function declaration:
42 * the engine follows `$` past an import only into one.
43 */
44export async function resolveWatch($: EngineInterface): Promise<Watch | undefined> {
45 const hit = await rootAndConfig($).catch(noop)
46 if (hit === undefined) return undefined
47 const cfg = readConfig(hit.text)
48 return isActive(cfg) ? { root: hit.root, cfg } : undefined
49}
50hooks/root.ts 54 lines1/**
2 * The project-root rule: the watch is active only in a project holding `.claude/harness.json`. Candidates
3 * are the session cwd and each folder above it (nearest first: a subfolder, or a worktree nested inside its
4 * checkout, finds the checkout's file), then CLAUDE_PROJECT_DIR; the first that holds the file is the root.
5 * A worktree outside the checkout finds only its own tracked file or falls back to CLAUDE_PROJECT_DIR (a
6 * known limitation). No process is spawned to find it. Pure: the `$` probe lives in watch.ts.
7 */
8
9export const HARNESS_CONFIG_REL = '.claude/harness.json'
10
11/** Folders above the cwd that are looked in, at most. */
12export const MAX_DEPTH = 32
13
14const keyOf = (p: string): string => p.toLowerCase()
15
16/** Forward slashes, no trailing slash (`/` stays `/`); undefined when blank. */
17export const normalDir = (v: string | undefined): string | undefined => {
18 const t = (v ?? '').trim().replace(/\\/g, '/')
19 if (t === '') return undefined
20 const s = t.replace(/\/+$/, '')
21 return s === '' ? '/' : s
22}
23const nonBlank = normalDir
24
25/** The folder above `dir`, or undefined at a filesystem root (`/`, `C:`, `//host/share`). */
26export const parentOf = (dir: string): string | undefined => {
27 const cut = dir.lastIndexOf('/')
28 if (cut < 0 || dir === '/' || /^\/\/[^/]+\/[^/]+$/.test(dir)) return undefined
29 return cut === 0 ? '/' : dir.slice(0, cut)
30}
31
32/** `dir` and the folders above it, nearest first, at most MAX_DEPTH + 1. */
33export const ancestorsOf = (dir: string): string[] => {
34 const out: string[] = []
35 for (let cur: string | undefined = dir; cur !== undefined && out.length <= MAX_DEPTH; cur = parentOf(cur)) {
36 out.push(cur)
37 }
38 return out
39}
40
41/** Candidate roots in probe order: the cwd and its ancestors, then CLAUDE_PROJECT_DIR; repeats dropped. */
42export const rootCandidates = (rawEnv: string | undefined, rawCwd: string | undefined): string[] => {
43 const cwd = nonBlank(rawCwd)
44 const out: string[] = []
45 for (const c of [...(cwd === undefined ? [] : ancestorsOf(cwd)), nonBlank(rawEnv)]) {
46 if (c !== undefined && !out.some(o => keyOf(o) === keyOf(c))) out.push(c)
47 }
48 return out
49}
50
51/** `<dir>/.claude/harness.json`, without a doubled slash at `/`. */
52export const configPathOf = (dir: string): string =>
53 `${dir.endsWith('/') ? dir : `${dir}/`}${HARNESS_CONFIG_REL}`
54types/index.d.ts 78 lines1/** One ranked TODO item as the summary keeps it. */
2export type TodoRow = {
3 id: string
4 rank: number
5 title: string
6 status: string
7 /** `priority.score`; null when the item has none. */
8 score: number | null
9}
10
11/** The TODO catalog's counts and its top-N items, read by the mod itself. */
12export type TodoSummary = {
13 /** `counts.items`. */
14 items: number
15 /** `counts.by_status.OPEN`. */
16 open: number
17 /** `counts.by_status.PARTIAL`. */
18 partial: number
19 /** `counts.by_status.BLOCKED`. */
20 blocked: number
21 /** `counts.needs_decision`. */
22 needsDecision: number
23 /** The first `max(todo.top_n, todo.notice.items)` items by rank. */
24 top: TodoRow[]
25 /** The catalog's mtime when it was read. */
26 mtimeMs: number
27}
28
29/** One finding of a sync report (TodoSyncReport `findings[]`). */
30export type SyncFinding = {
31 rule: string
32 severity: 'error' | 'warn'
33 counted: boolean
34 file: string | null
35 line: number | null
36 subject: string | null
37 message: string
38 fix: string | null
39}
40
41/** The parts of a sync report the mod reads. */
42export type SyncReport = {
43 generatedAt: string
44 drift: number
45 /** Counted findings per rule id. */
46 byRule: Record<string, number>
47 errors: number
48 warns: number
49 /** The issue cache's `fetched_at`; null when no cache was read. */
50 ghFetchedAt: string | null
51 findings: SyncFinding[]
52 /** The report file's mtime when it was read. */
53 mtimeMs: number
54}
55
56/** How the notice's status line reads: in sync, drifted, or checks unavailable. */
57export type NoticeTone = 'ok' | 'warn' | 'dim'
58
59/** The notice: the top items as `<rank> <title>` lines, then the sync status; null status while unknown. */
60export type NoticeView = { items: string[]; status: { text: string; tone: NoticeTone } | null }
61
62declare module 'claude-code' {
63 interface PluginState {
64 'todo-watch': {
65 /** The TODO catalog summary; null before the first read or when the catalog is unreadable. */
66 summary: TodoSummary | null
67 /** The latest report newer than the session start; null before one lands. */
68 report: SyncReport | null
69 /** The TODO_SYNC line last attached to a prompt. */
70 lastContext: string
71 /** The live notice view, recomputed on every catalog, report or checks-status change; null at a start. */
72 notice: NoticeView | null
73 /** Whether the band shows the notice: set at session start, cleared by Dismiss and the next start. */
74 isNoticeShown: boolean
75 }
76 }
77}
78