SLOPSHOPPER

todo-watch

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…

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
README

todo-watch

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.

Activation and configuration

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):

KeyDefaultUsed for
state_dir.claude/state/harness<state_dir>/todo-sync/runner.json (runner pointer) and report.json (sync report)
artifacts.researchdocs/RESEARCHwatched folder (writes under it re-check)
artifacts.plansdocs/PLANSwatched folder
todo.foldernullwhere the TODO files live; null turns the mod off
todo.catalogRepoToDo.json (under the folder)the TODO catalog the summary reads; watched file
todo.catalog_schemaRepoToDo.schema.json (under the folder)watched file; null: not watched
todo.top_n10rows in the /todo table (the catalog read keeps max(top_n, notice.items) rows)
todo.snapshots.dirSnapshots (under the folder)watched folder; null: not watched
todo.research.catalogResearchImplementationCatalog.json (under the folder)watched file; null: not watched
todo.gh.max_age_hours24gh <age> old suffix once the issue cache in the report is older
todo.inject_contexttruewhether prompts carry the TODO_SYNC line
todo.notice.stylebannerbanner or toast (M7); another value keeps the default
todo.notice.toast_seconds60seconds a toast stays; a value outside (0, 60] keeps the default
todo.notice.items3item 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.

Behavior

#RuleBehavior
M1The next items are visible when a session startsOne 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.
M2The model sees drift without anyone pasting itTODO_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.
M3Changing a watched file re-checks within the turnAfter 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.
M3bEdits made outside the session are re-checked tooThe same mtime probe on prompt.submit and on the main turn.complete.
M4Sync changes never nagA 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.
M5Full 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`.
M6The summary needs no engineThe mod reads the TODO catalog itself; the catalog and the report are re-read only when their mtime moved ($.fs.stat).
M7The notice shows as the project chosetodo.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.

Check runs

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.

Hooks

HookDoes
session.startRegisters /todo, shows the start notice, looks up the runner pointer and schedules the first check.
tool.call on Bash, PowerShell, Edit, Write, MultiEdit, NotebookEditAfter 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.submitThe runner retry, the report re-read, the probe, and the TODO_SYNC line (M2).
session.end, session.compactAfter /clear, a resume or a main compaction, the next prompt gets the line again.
command.run on todoAnswers /todo while the mod registered it; otherwise passes the command on.
ui.render on AbovePromptDraws the notice band with its Dismiss button (M7, banner style); passes while hidden, empty or under a survey.

State

$.state['todo-watch'] (contract: types/index.d.ts):

KeyMeaning
summaryThe catalog's counts and its top max(todo.top_n, todo.notice.items) items, with the catalog mtime; null when unreadable.
reportThe latest report newer than the session start (drift, by rule, counts, issue cache time, findings, mtime).
lastContextThe TODO_SYNC line last attached to a prompt.
noticeThe 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.
isNoticeShownWhether the band draws the notice: set at session start (banner style, non-empty view), cleared by Dismiss and at the next session start.

Known limitations

  • The root search is remembered per cwd for the life of the hooks module: a .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.
  • A nested .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.
  • A linked worktree outside its checkout's folder (the ~/.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.
  • The mtime probe lists the top level of each watched folder, and under 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.
  • A Bash or IDE change made between a check's start and its report is caught by the next probe; one made in the same millisecond as the check start is not.

Checks

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.

Loading

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.

Source 9 files
hooks/register.ts 22 lines
1import 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}
22
hooks/band.tsx 73 lines
1import 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}
73
hooks/checks.ts 590 lines
1import 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}
590
hooks/command.ts 43 lines
1import 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}
43
hooks/summary.ts 236 lines
1/**
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}
236
hooks/config.ts 180 lines
1/**
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`
180
hooks/watch.ts 50 lines
1import 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}
50
hooks/root.ts 54 lines
1/**
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}`
54
types/index.d.ts 78 lines
1/** 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