Shows the repository's TODOs in a scrolling band above the prompt, newest first: TODO.md items and TODO/FIXME/HACK code comments dated with git blame, a Mine…

A Claude Code mod that lists the repository's TODOs in a band above the prompt, newest first, so the open work is in front of you when a session starts.
╭────────────────────────────────────────────────────────────────────────────────────────────────[-]
│ todos · 12 │
│ ○ src/new.ts:2 cache the answer #9 today │
│ ● TODO.md Write the README before the release 11d │
│ ○ TODO.md Add a CI job for the plugin tests 11d │
│ ● deploy.sh:3 move secrets to the vault #7 11d │
│ ○ src/old.ts:2 FIXME handles CRLF badly 1y │
│ 1–5 of 12 · 2 selected [ Show Claude ] [ Clear ] [ Mine ] [ Hide ] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────╯
/plugin marketplace add bengous/claude-code-plugins
/plugin install todos@bengous-plugins
A mod runs inside Claude Code with your permissions; read hooks/register.ts before you install it. Mods need a Claude Code version that ships them, and they draw only in the terminal and in the Code tab of the Desktop app: see Where mods run.
TODO.md at the repository root: each top-level - or * item, and each unchecked - [ ] box. A checked box is done and left out. A title stops at its first : or ; past ten characters.TODO, FIXME and HACK by default): after //, /*, #, --, <!-- or ;, or after the * that continues a block comment. const TODO_LIST, a TODO inside a sentence, or a comment opened inside a string, such as a test's "// TODO: x", is not one.The comment search is git grep over tracked and untracked files, .gitignore respected. It skips prose, data and diff files (.md, .txt, .json, .jsonl, .csv, .lock, .svg, .log, .patch and a few more), where a marker is text about TODOs or a transcript quoting one.
It also skips the paths your .gitattributes marks -todos, and those it marks linguist-vendored or linguist-generated. TODO.md is read whatever its attributes.
archive/** -todos
Each TODO is dated with git blame: the time its line was authored. The newest comes first. A line not committed yet, or in an untracked file, dates to the scan and reads today.
/todos scans again and reopens it.rows TODOs at a time under a header and a footer that stay put. A longer list gets a row of arrows under it, 8: ↑ 3 more at the left and 9: ↓ 4 more at the right: type the digit alone into an empty prompt and pause, or click the arrow, and the list moves by a page. PgUp and PgDn scroll it too once the band is focused (ctrl+x tab or a click), Home and End by a page as well, and so does the wheel over the list where Claude Code reads the mouse. At either end of the list a scroll moves the band itself, when it is taller than its room. The footer says which rows show, 1–5 of 12.○ before a TODO picks it: click it, or Tab to it and press Enter. Show Claude puts the picked TODOs in the prompt box at the cursor, under Read these TODOs:, one - path:line: text per TODO (the path from the repository's root, absolute when the session started in another directory), and gives the keys back to the prompt, where you write what Claude should do with them before you send. Clear drops the picks.user.email, uncommitted lines included. All shows every one again.TODO(#42) links to issue 42 when origin is on GitHub.When Claude edits a file with Edit or Write and adds a TODO, a toast names it: New FIXME at deploy.sh:4: rotate the deploy key. A file the scan skips raises none.
# TODO inside a multi-line string, such as a Python """ block, is listed.git blame runs once per file that holds a TODO, as many at a time as the machine has CPUs, sixteen at most (six on Windows, where getconf is missing), so the band of a repository with about a hundred such files opens after close to a second on a 16-CPU machine. Each edit that adds a TODO scans again.+N more.8 or 9 typed alone into an empty prompt, then a pause, pages the list: it does not start your message./todos, drops the pick.Claude Code asks for them when you enable the plugin; they are also rows in /config.
| Setting | Default | What it does |
|---|---|---|
show_on_start | true | Open the band when a session starts. /todos opens it either way. |
rows | 8 | How many TODOs the band shows at once, the rest a scroll away; it shows fewer in a short terminal. |
markers | TODO,FIXME,HACK | The comment words that mark a TODO, separated by commas. |
mine_only | false | Start each session with Mine on. |
| Path | What it holds |
|---|---|
hooks/register.ts | The hooks: session start, prompt, /todos, the edit toast, the band |
hooks/scan.ts | The scan: git grep, TODO.md, git blame, through a host the hooks build from $ |
hooks/parse.ts | Pure parsing and formatting, covered by bun test |
hooks/register.test.ts | Tests for claude plugin test, run in Claude Code's own test kit |
hooks/register.ts 522 lines1import type { EngineInterface, PluginOptions, Register } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import type { TodoScan } from "../types/index.d.ts";
5import {
6 addedTodos,
7 DEFAULT_MARKERS,
8 displayText,
9 formatAge,
10 isMine,
11 isScannedPath,
12 issueNumber,
13 LIST_FILE,
14 parseMarkers,
15 pickedTodos,
16 promptText,
17 shortenStart,
18 sourceLabel,
19 todoId,
20} from "./parse.ts";
21import { type Host, isExcludedPath, locate, repoRoot, type ScanResult, scanRepo } from "./scan.ts";
22
23const NO_SCAN: TodoScan | null = null;
24
25const scanState = atom({ plugin: "todos", key: "scan" } as const, NO_SCAN);
26
27const isVisible = atom({ plugin: "todos", key: "visible" } as const, false);
28
29const isMineOnly = atom({ plugin: "todos", key: "mineOnly" } as const, false);
30
31const windowOffset = atom({ plugin: "todos", key: "offset" } as const, 0);
32
33const NOTHING_PICKED: readonly string[] = [];
34
35const pickedIds = atom({ plugin: "todos", key: "picked" } as const, NOTHING_PICKED);
36
37const COMMAND = "todos";
38
39const GIT_TIMEOUT_MS = 20_000;
40
41const DEFAULT_ROWS = 8;
42
43// $.state refuses a value over 4,194,304 characters, and a TODO takes a few hundred.
44const KEPT_TODOS = 500;
45
46const MIN_SOURCE_WIDTH = 16;
47
48const SOURCE_SHARE = 0.35;
49
50// The band's border and its header and footer rows.
51const BAND_CHROME_ROWS = 4;
52
53const PAGER_ROWS = 1;
54
55// A bare digit typed into an empty prompt presses a band Button: the list pages without the band's focus.
56const PAGE_UP_HOTKEY = "8";
57
58const PAGE_DOWN_HOTKEY = "9";
59
60// The band's top border and its header.
61const LIST_FIRST_ROW = 2;
62
63type Settings = {
64 readonly showOnStart: boolean;
65 readonly rows: number;
66 readonly markers: readonly string[];
67 readonly mineOnly: boolean;
68};
69
70// Claude Code checks the options against plugin.json's userConfig and fills in its defaults before register runs.
71function settingsOf(options: PluginOptions): Settings {
72 return {
73 showOnStart: options.show_on_start !== false,
74 rows: Math.floor(Number(options.rows ?? DEFAULT_ROWS)),
75 markers: parseMarkers(String(options.markers ?? DEFAULT_MARKERS.join(","))),
76 mineOnly: options.mine_only === true,
77 };
78}
79
80function messageOf(error: Error | string): string {
81 return error instanceof Error ? error.message : error;
82}
83
84function hostOf($: EngineInterface): Host {
85 return {
86 run: (argv, cwd) => $.process.run(argv, { cwd, timeoutMs: GIT_TIMEOUT_MS }),
87 exists: (path) => $.fs.exists(path),
88 read: (path) => $.fs.read(path),
89 };
90}
91
92function kept(scan: ScanResult): TodoScan {
93 const mine = scan.todos.filter((todo) => isMine(todo, scan.userEmail));
94
95 return {
96 scannedAt: scan.scannedAt,
97 root: scan.root,
98 issueBase: scan.issueBase,
99 total: scan.todos.length,
100 mineTotal: mine.length,
101 todos: scan.todos.slice(0, KEPT_TODOS),
102 mineTodos: mine.slice(0, KEPT_TODOS),
103 };
104}
105
106function countText(scan: TodoScan, mineOnly: boolean): string {
107 if (scan.total === 0) return "No TODOs in this repository.";
108 const counted = `${scan.total} ${scan.total === 1 ? "TODO" : "TODOs"} above the prompt`;
109
110 return mineOnly ? `${counted}, ${scan.mineTotal} of them yours.` : `${counted}.`;
111}
112
113// Scans overlap when an edit lands during one: only the latest to start writes the band.
114let latestScan = 0;
115
116async function refresh(
117 $: EngineInterface,
118 settings: Settings,
119 cwd: string,
120 reveal: boolean,
121): Promise<TodoScan | null> {
122 const generation = ++latestScan;
123 const host = hostOf($);
124 const root = await repoRoot(host, cwd);
125
126 if (root === null) return null;
127 const scan = await scanRepo(host, root, settings.markers, await $.clock.now());
128 const [failure, ...others] = scan.failures;
129
130 if (failure !== undefined) {
131 $.ui.toast(`${failure}${others.length > 0 ? ` (+${others.length} more)` : ""}`);
132 }
133
134 const state = kept(scan);
135
136 if (generation === latestScan) {
137 await update($, scanState, () => state);
138 await update($, pickedIds, (ids) => pickedTodos(state, ids).map((todo) => todoId(todo)));
139 }
140
141 if (reveal) {
142 await update($, windowOffset, () => 0);
143 await update($, isVisible, () => state.total > 0);
144 }
145
146 return state;
147}
148
149async function refreshOrToast(
150 $: EngineInterface,
151 settings: Settings,
152 cwd: string,
153 reveal: boolean,
154): Promise<void> {
155 try {
156 await refresh($, settings, cwd, reveal);
157 } catch (error) {
158 $.ui.toast(messageOf(error instanceof Error ? error : String(error)));
159 }
160}
161
162function windowStart(offset: number, count: number, windowRows: number): number {
163 return Math.max(0, Math.min(offset, count - windowRows));
164}
165
166function pagedOffset(offset: number, by: number, count: number, windowRows: number): number {
167 return windowStart(windowStart(offset, count, windowRows) + by, count, windowRows);
168}
169
170async function showClaude($: EngineInterface): Promise<void> {
171 const scan = await read($, scanState);
172
173 if (scan === null) return;
174 const todos = pickedTodos(scan, await read($, pickedIds));
175
176 if (todos.length === 0) return;
177 const root = (await $.session.cwd()) === scan.root ? null : scan.root;
178 const box = await $.prompt.read();
179 const before = box.text.slice(0, box.cursor);
180
181 // oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- $.prompt.fill writes the prompt box; it is no Array.prototype.fill
182 const filled = await $.prompt.fill({
183 text: `${before === "" || before.endsWith("\n") ? "" : "\n"}${promptText(todos, root)}`,
184 mode: "insert",
185 });
186
187 if (!filled.isFilled) {
188 $.ui.toast(
189 `The prompt box did not take the TODOs${filled.refusal === undefined ? "" : ` (${filled.refusal})`}.`,
190 );
191
192 return;
193 }
194
195 await update($, pickedIds, () => NOTHING_PICKED);
196}
197
198export const register: Register = (on, options) => {
199 const settings = settingsOf(options);
200 // A scroll event's bodyRows is not the band's maxRows, so the scroll hook clamps to the window the band last drew.
201 let drawnRows = settings.rows;
202 let isPagerDrawn = false;
203
204 on("session.start", async ($, e, next) => {
205 try {
206 await $.command.register({
207 name: COMMAND,
208 description: "Show this repository's TODOs above the prompt, newest first",
209 });
210 } catch (error) {
211 $.ui.toast(
212 `/${COMMAND} is not available: ${messageOf(error instanceof Error ? error : String(error))}`,
213 );
214 }
215
216 await update($, isMineOnly, () => settings.mineOnly);
217
218 // Not awaited: Claude Code holds the first prompt until session.start returns, and a scan blames every file it finds.
219 if (e.isInteractive) void refreshOrToast($, settings, e.cwd, settings.showOnStart);
220
221 return next(e);
222 });
223
224 // /clear, /resume and /branch reset $.state, and session.start does not fire again.
225 on("classic.SessionStart", { source: ["clear", "resume", "fork"] }, async ($, e, next) => {
226 await update($, isMineOnly, () => settings.mineOnly);
227 void refreshOrToast($, settings, e.cwd, settings.showOnStart);
228
229 return next(e);
230 });
231
232 on("prompt.submit", async ($, e, next) => {
233 if (await read($, isVisible)) await update($, isVisible, () => false);
234
235 return next(e);
236 });
237
238 on("command.run", { command: COMMAND }, async ($) => {
239 try {
240 const scan = await refresh($, settings, await $.session.cwd(), true);
241
242 if (scan === null) return { text: "Not inside a git repository." };
243
244 return { text: countText(scan, await read($, isMineOnly)) };
245 } catch (error) {
246 return { text: messageOf(error instanceof Error ? error : String(error)) };
247 }
248 });
249
250 on("tool.call", { tool: ["Edit", "Write"] }, async ($, e, next) => {
251 if (e.tool !== "Edit" && e.tool !== "Write") return next(e);
252 const written = e.tool === "Write" ? e.content : e.new_string;
253
254 const name = e.file_path.slice(
255 Math.max(e.file_path.lastIndexOf("/"), e.file_path.lastIndexOf("\\")) + 1,
256 );
257
258 if (name !== LIST_FILE && !settings.markers.some((marker) => written.includes(marker)))
259 return next(e);
260 const located = await locate(hostOf($), e.file_path);
261
262 if (located === null || !isScannedPath(located.path)) return next(e);
263 const before = (await $.fs.exists(e.file_path)) ? await $.fs.read(e.file_path) : "";
264 const result = await next(e);
265
266 if ("deny" in result || result.isError === true) return result;
267 const added = addedTodos(located.path, before, await $.fs.read(e.file_path), settings.markers);
268 const [first, ...others] = added;
269
270 if (
271 first === undefined ||
272 (located.path !== LIST_FILE && (await isExcludedPath(hostOf($), located.root, located.path)))
273 )
274 return result;
275 const where = located.path === LIST_FILE ? located.path : `${located.path}:${first.line}`;
276
277 $.ui.toast(
278 `New ${first.marker} at ${where}: ${first.text === "" ? "(no description)" : first.text}${others.length > 0 ? ` (+${others.length} more)` : ""}`,
279 );
280
281 if ((await read($, scanState))?.root === located.root)
282 void refreshOrToast($, settings, located.root, false);
283
284 return result;
285 });
286
287 on("ui.scroll", { component: "AbovePrompt" }, async ($, e, next) => {
288 if (e.origin.kind !== "person" || !(await read($, isVisible))) return next(e);
289
290 // Only in a band that fits its window is the pointer's row the tree's row.
291 if (
292 e.pointer !== undefined &&
293 e.contentRows <= e.bodyRows &&
294 (e.pointer.row < LIST_FIRST_ROW ||
295 e.pointer.row >= LIST_FIRST_ROW + drawnRows + (isPagerDrawn ? PAGER_ROWS : 0))
296 )
297 return next(e);
298 const scan = await read($, scanState);
299
300 if (scan === null) return next(e);
301 const count = ((await read($, isMineOnly)) ? scan.mineTodos : scan.todos).length;
302 const offset = await read($, windowOffset);
303 const from = windowStart(offset, count, drawnRows);
304
305 const to = pagedOffset(
306 offset,
307 Math.sign(e.by) * Math.min(Math.abs(e.by), drawnRows),
308 count,
309 drawnRows,
310 );
311
312 // At its edge the list hands the move on: the engine scrolls a band taller than its window.
313 if (to === from) return next(e);
314 await update($, windowOffset, () => to);
315
316 return {};
317 });
318
319 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
320 const scan = await read($, scanState);
321
322 if (e.props.hasSurvey || scan === null || scan.total === 0 || !(await read($, isVisible))) {
323 return next(e);
324 }
325
326 const { Box, Button, Link, Text } = $.ui.resolve(e);
327 const mineOnly = await read($, isMineOnly);
328 const listed = mineOnly ? scan.mineTodos : scan.todos;
329 const listedTotal = mineOnly ? scan.mineTotal : scan.total;
330
331 const fitRows = (chromeRows: number) =>
332 Math.max(1, Math.min(settings.rows, e.props.maxRows - chromeRows));
333
334 const hasPagerRoom = e.props.maxRows - BAND_CHROME_ROWS - PAGER_ROWS >= 1;
335 const isPaged = hasPagerRoom && listed.length > fitRows(BAND_CHROME_ROWS);
336 const windowRows = fitRows(isPaged ? BAND_CHROME_ROWS + PAGER_ROWS : BAND_CHROME_ROWS);
337 drawnRows = windowRows;
338 isPagerDrawn = isPaged;
339 const start = windowStart(await read($, windowOffset), listed.length, windowRows);
340 const shown = listed.slice(start, start + windowRows);
341 const below = listed.length - start - shown.length;
342 const picked = await read($, pickedIds);
343
344 const labelLimit = Math.max(MIN_SOURCE_WIDTH, Math.floor(e.props.bodyColumns * SOURCE_SHARE));
345 // Measured over the whole list, so the columns hold still while it scrolls.
346 const labels = listed.map((todo) => shortenStart(sourceLabel(todo), labelLimit));
347 const ages = listed.map((todo) => formatAge(scan.scannedAt - todo.authoredAt));
348 const labelWidth = Math.max(0, ...labels.map((label) => label.length));
349 const ageWidth = Math.max(0, ...ages.map((age) => age.length));
350 const unkeptCount = listedTotal - listed.length;
351 const count = mineOnly ? `${scan.mineTotal} of ${scan.total} yours` : `${scan.total}`;
352
353 const footer = [
354 listed.length > shown.length
355 ? `${start + 1}–${start + shown.length} of ${listed.length}`
356 : "",
357 unkeptCount > 0 ? `+${unkeptCount} more` : "",
358 picked.length > 0 ? `${picked.length} selected` : "hides on your next message",
359 picked.length > 0 ? "" : `/${COMMAND} reopens`,
360 ]
361 .filter((part) => part !== "")
362 .join(" · ");
363
364 const issueOf = (tag: string | null) => {
365 const number = issueNumber(tag);
366
367 if (number === null) return [];
368
369 return [
370 Box({
371 flexShrink: 0,
372 children: [
373 scan.issueBase === null
374 ? Text({ dimColor: true, children: `#${number}` })
375 : Link({ href: `${scan.issueBase}/issues/${number}`, label: `#${number}` }),
376 ],
377 }),
378 ];
379 };
380
381 const togglePicked = (id: string) =>
382 update($, pickedIds, (ids) =>
383 ids.includes(id) ? ids.filter((other) => other !== id) : [...ids, id],
384 );
385
386 const pageBy = (by: number) =>
387 update($, windowOffset, (offset) => pagedOffset(offset, by, listed.length, windowRows));
388
389 // An arrow at its end stays drawn, dim: unarmed, its digit would land in the prompt and block the other.
390 // Each keeps its own end of the row, so a second click lands on the arrow the first one hit.
391 const pager = isPaged
392 ? [
393 Box({
394 justifyContent: "space-between",
395 children: [
396 Button({
397 key: "up",
398 label: `↑ ${start} more`,
399 hotkey: PAGE_UP_HOTKEY,
400 plain: true,
401 dimColor: start === 0,
402 onPress: () => pageBy(-windowRows),
403 }),
404 Button({
405 key: "down",
406 label: `↓ ${below} more`,
407 hotkey: PAGE_DOWN_HOTKEY,
408 plain: true,
409 dimColor: below === 0,
410 onPress: () => pageBy(windowRows),
411 }),
412 ],
413 }),
414 ]
415 : [];
416
417 const rows = shown.map((todo, index) =>
418 Box({
419 key: `todo:${todoId(todo)}`,
420 gap: 1,
421 children: [
422 Button({
423 key: `pick:${todoId(todo)}`,
424 label: picked.includes(todoId(todo)) ? "●" : "○",
425 plain: true,
426 dimColor: !picked.includes(todoId(todo)),
427 onPress: () => togglePicked(todoId(todo)),
428 }),
429 Box({
430 width: labelWidth,
431 flexShrink: 0,
432 children: [Text({ dimColor: true, children: labels[start + index] ?? "" })],
433 }),
434 Box({
435 flexGrow: 1,
436 flexShrink: 1,
437 minWidth: 0,
438 children: [Text({ wrap: "truncate-end", children: displayText(todo) })],
439 }),
440 ...issueOf(todo.tag),
441 Box({
442 width: ageWidth,
443 flexShrink: 0,
444 children: [Text({ dimColor: true, children: ages[start + index] ?? "" })],
445 }),
446 ],
447 }),
448 );
449
450 const band = Box({
451 flexDirection: "column",
452 borderStyle: "round",
453 borderColor: "cyan",
454 paddingX: 1,
455 children: [
456 Box({
457 gap: 1,
458 children: [
459 Text({ bold: true, color: "cyan", children: "todos" }),
460 Text({ dimColor: true, children: `· ${count}` }),
461 ],
462 }),
463 ...(rows.length > 0
464 ? rows
465 : [Text({ dimColor: true, children: "None of these TODOs is yours." })]),
466 ...pager,
467 Box({
468 gap: 1,
469 children: [
470 Box({
471 flexGrow: 1,
472 flexShrink: 1,
473 minWidth: 0,
474 children: [Text({ dimColor: true, wrap: "truncate-end", children: footer })],
475 }),
476 Box({
477 flexShrink: 0,
478 gap: 1,
479 children: [
480 ...(picked.length > 0
481 ? [
482 Button({
483 key: "show",
484 label: "Show Claude",
485 variant: "primary",
486 onPress: () => showClaude($),
487 }),
488 Button({
489 key: "clear",
490 label: "Clear",
491 dimColor: true,
492 onPress: () => update($, pickedIds, () => NOTHING_PICKED),
493 }),
494 ]
495 : []),
496 Button({
497 key: "mine",
498 label: mineOnly ? "All" : "Mine",
499 dimColor: true,
500 onPress: async () => {
501 await update($, isMineOnly, (value) => !value);
502 await update($, windowOffset, () => 0);
503 },
504 }),
505 Button({
506 key: "hide",
507 label: "Hide",
508 dimColor: true,
509 onPress: () => update($, isVisible, () => false),
510 }),
511 ],
512 }),
513 ],
514 }),
515 ],
516 });
517
518 // The band is shared: what the mods after this one draw stays below the list.
519 return Box({ flexDirection: "column", children: [band, await next(e)] });
520 });
521};
522types/index.d.ts 37 lines1export type TodoSource = "list" | "comment";
2
3export type Todo = {
4 readonly source: TodoSource;
5 readonly path: string;
6 readonly line: number;
7 readonly marker: string;
8 readonly tag: string | null;
9 readonly text: string;
10 readonly authoredAt: number;
11 readonly commit: string | null;
12 readonly authorEmail: string | null;
13};
14
15/** What the band keeps of a scan: the counts, and only as many TODOs as it scrolls through, since $.state caps a value's size. */
16export type TodoScan = {
17 readonly scannedAt: number;
18 readonly root: string;
19 readonly issueBase: string | null;
20 readonly total: number;
21 readonly mineTotal: number;
22 readonly todos: readonly Todo[];
23 readonly mineTodos: readonly Todo[];
24};
25
26declare module "claude-code" {
27 interface PluginState {
28 todos: {
29 scan: TodoScan | null;
30 visible: boolean;
31 mineOnly: boolean;
32 offset: number;
33 picked: readonly string[];
34 };
35 }
36}
37hooks/parse.ts 385 lines1import type { Todo, TodoScan } from "../types/index.d.ts";
2
3export const DEFAULT_MARKERS: readonly string[] = ["TODO", "FIXME", "HACK"];
4
5export const LIST_FILE = "TODO.md";
6
7/** Prose and data files: a marker there is text about TODOs, or a transcript quoting one, never a comment. */
8export const SKIPPED_EXTENSIONS: readonly string[] = [
9 "md",
10 "mdx",
11 "markdown",
12 "txt",
13 "rst",
14 "adoc",
15 "json",
16 "jsonl",
17 "ndjson",
18 "csv",
19 "tsv",
20 "lock",
21 "svg",
22 "log",
23 "map",
24 "patch",
25 "diff",
26];
27
28const EXCLUDING_ATTRIBUTES = [
29 { name: "todos", value: "unset" },
30 { name: "linguist-vendored", value: "set" },
31 { name: "linguist-vendored", value: "true" },
32 { name: "linguist-generated", value: "set" },
33 { name: "linguist-generated", value: "true" },
34] as const;
35
36export const EXCLUDING_ATTRIBUTE_NAMES: readonly string[] = [
37 ...new Set(EXCLUDING_ATTRIBUTES.map((attribute) => attribute.name)),
38];
39
40function attributeRequirement(name: string, value: string): string {
41 if (value === "unset") return `-${name}`;
42
43 return value === "set" ? name : `${name}=${value}`;
44}
45
46export const ATTRIBUTE_PATHSPECS: readonly string[] = EXCLUDING_ATTRIBUTES.map(
47 ({ name, value }) => `:(exclude,attr:${attributeRequirement(name, value)})`,
48);
49
50const QUOTES = ['"', "'", "`"];
51
52const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
53
54export type CommentTodo = {
55 readonly marker: string;
56 readonly tag: string | null;
57 readonly text: string;
58};
59
60export type GrepHit = CommentTodo & { readonly path: string; readonly line: number };
61
62export type ListItem = { readonly line: number; readonly text: string };
63
64export type Origin = {
65 readonly commit: string | null;
66 readonly authoredAt: number;
67 readonly authorEmail: string | null;
68};
69
70const DAY_MS = 86_400_000;
71
72const UNCOMMITTED_SHA = /^0{40}$/u;
73
74const BLAME_HEADER = /^([0-9a-f]{40}) \d+ (\d+)/u;
75
76const LIST_ITEM = /^[-*] (?:\[ \] )?(.*)$/u;
77
78const DONE_ITEM = /^[-*] \[[xX]\] /u;
79
80const COMMENT_END = /\*\/|-->/u;
81
82const LINE_BREAK = /\r?\n/u;
83
84// The engine refuses a whole band whose text holds a control character or passes 10,000 characters.
85const MAX_TEXT = 200;
86
87const NEVER = /(?!)/uy;
88
89const ISSUE_TAG = /^#(\d+)$/u;
90
91const GITHUB_REMOTE =
92 /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/u;
93
94const escapeRegExp = (text: string): string =>
95 text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
96
97/**
98 * A marker counts only as the first word of a comment: after `//`, `/*`, `#`, `--`, `<!--` or `;`
99 * that starts the line or follows a space, a bracket, `;` or `,`, or after the `*` that continues a block comment.
100 */
101export function commentPattern(markers: readonly string[]): RegExp {
102 if (markers.length === 0) return NEVER;
103 const alternatives = markers.map((marker) => escapeRegExp(marker)).join("|");
104
105 return new RegExp(
106 String.raw`(?:^\s*\*+|(?:^|[\s(){}[\];,])(?:\/\/+|\/\*+|#+|--+|<!--|;+))[\s!]*(${alternatives})\b(?:\(([^)]*)\))?:?\s*(.*)$`,
107 "uy",
108 );
109}
110
111const isControl = (character: string): boolean => {
112 const code = character.codePointAt(0) ?? 0;
113
114 return code < 0x20 || (code >= 0x7f && code < 0xa0);
115};
116
117export function stripControl(text: string): string {
118 return [...text.replaceAll("\t", " ")].filter((character) => !isControl(character)).join("");
119}
120
121export function cleanText(text: string): string {
122 const plain = stripControl(text).trim();
123
124 return plain.length > MAX_TEXT ? `${plain.slice(0, MAX_TEXT - 1)}…` : plain;
125}
126
127/**
128 * A string closes at its quote unescaped and not followed by a letter, so the `'` of `don't` or of
129 * a Rust lifetime closes nothing; -1 when it does not close on this line.
130 */
131function stringEnd(line: string, start: number): number {
132 const quote = line.charAt(start);
133
134 for (let index = start + 1; index < line.length; index += 1) {
135 const character = line.charAt(index);
136
137 if (character === "\\") index += 1;
138 else if (character === quote && !WORD_CHARACTER.test(line.charAt(index + 1))) return index;
139 }
140
141 return -1;
142}
143
144/** The first comment the pattern finds outside a string: a test fixture's `"// TODO"` is not one. */
145export function commentTodo(text: string, pattern: RegExp): CommentTodo | null {
146 const line = text.endsWith("\r") ? text.slice(0, -1) : text;
147
148 for (let index = 0; index < line.length; index += 1) {
149 pattern.lastIndex = index;
150 const match = pattern.exec(line);
151 const marker = match?.[1];
152
153 if (match !== null && marker !== undefined) {
154 const tag = cleanText(match[2] ?? "");
155
156 return {
157 marker,
158 tag: tag === "" ? null : tag,
159 text: cleanText((match[3] ?? "").split(COMMENT_END)[0] ?? ""),
160 };
161 }
162
163 const character = line.charAt(index);
164
165 if (character === "\\") index += 1;
166 else if (QUOTES.includes(character)) index = Math.max(index, stringEnd(line, index));
167 }
168
169 return null;
170}
171
172/** `git grep -z -n` rows: path, NUL, line number, NUL, the line's text. */
173export function parseGrep(stdout: string, markers: readonly string[]): GrepHit[] {
174 const pattern = commentPattern(markers);
175
176 return stdout.split("\n").flatMap((row) => {
177 const [path, line, text] = row.split("\0");
178
179 if (path === undefined || line === undefined || text === undefined) return [];
180 const found = commentTodo(text, pattern);
181
182 return found === null ? [] : [{ ...found, path, line: Number(line) }];
183 });
184}
185
186/** Top-level `- ` or `* ` items, unchecked boxes included and checked ones left out; the title stops at its first `:` or `;` past ten characters. */
187export function parseList(text: string): ListItem[] {
188 return text.split(LINE_BREAK).flatMap((row, index) => {
189 if (DONE_ITEM.test(row)) return [];
190 const item = LIST_ITEM.exec(row)?.[1];
191 const body = item === undefined ? undefined : cleanText(item.replaceAll("`", ""));
192
193 if (body === undefined || body === "") return [];
194 const cut = body.search(/[:;]/u);
195
196 return [{ line: index + 1, text: cut > 10 ? body.slice(0, cut) : body }];
197 });
198}
199
200/** `git blame --line-porcelain` output, keyed by the line's number in the working tree. */
201export function parseBlame(porcelain: string): Map<number, Origin> {
202 const origins = new Map<number, Origin>();
203 let sha = "";
204 let finalLine = 0;
205 let authoredAt = 0;
206 let authorEmail = "";
207
208 for (const row of porcelain.split("\n")) {
209 const header = BLAME_HEADER.exec(row);
210
211 if (header !== null) {
212 sha = header[1] ?? "";
213 finalLine = Number(header[2]);
214 } else if (row.startsWith("author-mail ")) {
215 authorEmail = row.slice("author-mail ".length).replaceAll(/^<|>$/gu, "");
216 } else if (row.startsWith("author-time ")) {
217 authoredAt = Number(row.slice("author-time ".length)) * 1000;
218 } else if (row.startsWith("\t")) {
219 const isUncommitted = UNCOMMITTED_SHA.test(sha);
220 origins.set(finalLine, {
221 commit: isUncommitted ? null : sha.slice(0, 8),
222 authoredAt,
223 authorEmail: isUncommitted ? null : authorEmail,
224 });
225 }
226 }
227
228 return origins;
229}
230
231const sourceRank = (todo: Todo): number => (todo.source === "list" ? 0 : 1);
232
233export function sortNewestFirst(todos: readonly Todo[]): Todo[] {
234 return todos.toSorted(
235 (a, b) =>
236 b.authoredAt - a.authoredAt ||
237 sourceRank(a) - sourceRank(b) ||
238 a.path.localeCompare(b.path) ||
239 a.line - b.line,
240 );
241}
242
243export function formatAge(ms: number): string {
244 const days = Math.floor(ms / DAY_MS);
245
246 if (days < 1) return "today";
247
248 if (days < 14) return `${days}d`;
249
250 if (days < 60) return `${Math.floor(days / 7)}w`;
251
252 if (days < 365) return `${Math.floor(days / 30)}mo`;
253
254 return `${Math.floor(days / 365)}y`;
255}
256
257export function sourceLabel(todo: Todo): string {
258 return stripControl(todo.source === "list" ? todo.path : `${todo.path}:${todo.line}`);
259}
260
261/** Keeps the end of a path, where the file name and line are. */
262export function shortenStart(text: string, width: number): string {
263 return text.length <= width ? text : `…${text.slice(text.length - width + 1)}`;
264}
265
266export function displayText(todo: Todo): string {
267 const text = todo.text === "" ? "(no description)" : todo.text;
268
269 return todo.source === "comment" && todo.marker !== "TODO" ? `${todo.marker} ${text}` : text;
270}
271
272/** The line is part of it: a rescan that moves a TODO drops it from the picked ones. */
273export function todoId(todo: Todo): string {
274 return JSON.stringify([todo.source, todo.path, todo.line, todo.marker, todo.text]);
275}
276
277export function pickedTodos(scan: TodoScan, ids: readonly string[]): Todo[] {
278 const byId = new Map([...scan.todos, ...scan.mineTodos].map((todo) => [todoId(todo), todo]));
279
280 return [...byId.values()].filter((todo) => ids.includes(todoId(todo)));
281}
282
283/**
284 * What Show Claude puts in the prompt box: where each TODO is, and no verb that asks for a fix.
285 * Paths are the repository's: a session started elsewhere gets them under `root`.
286 */
287export function promptText(todos: readonly Todo[], root: string | null): string {
288 const items = todos.map(
289 (todo) =>
290 `- ${root === null ? "" : `${root}/`}${stripControl(todo.path)}:${todo.line}: ${displayText(todo)}${todo.tag === null ? "" : ` (${todo.tag})`}`,
291 );
292
293 return ["Read these TODOs:", ...items, ""].join("\n");
294}
295
296/** The markers setting: words separated by commas or spaces, each kept once. */
297export function parseMarkers(setting: string): string[] {
298 return [...new Set(setting.split(/[\s,]+/u).filter((word) => word !== ""))];
299}
300
301/** The web address of a GitHub remote, where `TODO(#42)` links to; null for any other host. */
302export function githubWebUrl(remote: string): string | null {
303 const slug = GITHUB_REMOTE.exec(remote.trim())?.[1];
304
305 return slug === undefined ? null : `https://github.com/${slug}`;
306}
307
308export function issueNumber(tag: string | null): number | null {
309 const digits = tag === null ? undefined : ISSUE_TAG.exec(tag)?.[1];
310
311 return digits === undefined ? null : Number(digits);
312}
313
314/** An uncommitted line is the person's own work. */
315export function isMine(todo: Todo, email: string | null): boolean {
316 if (todo.authorEmail === null) return true;
317
318 return email !== null && todo.authorEmail.toLowerCase() === email.toLowerCase();
319}
320
321/** Whether a scan reads this repository-relative path: TODO.md, or a file outside the skipped extensions. */
322export function isScannedPath(path: string): boolean {
323 if (path === LIST_FILE) return true;
324 const name = path.slice(path.lastIndexOf("/") + 1);
325 const dot = name.lastIndexOf(".");
326
327 return dot <= 0 || !SKIPPED_EXTENSIONS.includes(name.slice(dot + 1).toLowerCase());
328}
329
330/** `git check-attr -z` rows: path, attribute and value, each followed by NUL. */
331export function isExcludedByAttributes(stdout: string): boolean {
332 const fields = stdout.split("\0");
333
334 for (let index = 0; index + 2 < fields.length; index += 3) {
335 const [name, value] = [fields[index + 1], fields[index + 2]];
336
337 if (
338 EXCLUDING_ATTRIBUTES.some((attribute) => attribute.name === name && attribute.value === value)
339 )
340 return true;
341 }
342
343 return false;
344}
345
346export type AddedTodo = CommentTodo & { readonly line: number };
347
348const todoKey = (todo: CommentTodo): string => `${todo.marker}\0${todo.text}`;
349
350function todosOfText(path: string, text: string, pattern: RegExp): AddedTodo[] {
351 if (path === LIST_FILE) {
352 return parseList(text).map((item) => ({ ...item, marker: "TODO", tag: null }));
353 }
354
355 return text.split(LINE_BREAK).flatMap((row, index) => {
356 const found = commentTodo(row, pattern);
357
358 return found === null ? [] : [{ ...found, line: index + 1 }];
359 });
360}
361
362/** The TODOs `after` holds and `before` did not, matched by marker and text so that a moved line is not new. */
363export function addedTodos(
364 path: string,
365 before: string,
366 after: string,
367 markers: readonly string[],
368): AddedTodo[] {
369 const pattern = commentPattern(markers);
370 const remaining = new Map<string, number>();
371
372 for (const todo of todosOfText(path, before, pattern)) {
373 remaining.set(todoKey(todo), (remaining.get(todoKey(todo)) ?? 0) + 1);
374 }
375
376 return todosOfText(path, after, pattern).filter((todo) => {
377 const count = remaining.get(todoKey(todo)) ?? 0;
378
379 if (count === 0) return true;
380 remaining.set(todoKey(todo), count - 1);
381
382 return false;
383 });
384}
385hooks/scan.ts 267 lines1import type { Todo } from "../types/index.d.ts";
2import {
3 ATTRIBUTE_PATHSPECS,
4 EXCLUDING_ATTRIBUTE_NAMES,
5 githubWebUrl,
6 type GrepHit,
7 isExcludedByAttributes,
8 LIST_FILE,
9 type Origin,
10 parseBlame,
11 parseGrep,
12 parseList,
13 SKIPPED_EXTENSIONS,
14 sortNewestFirst,
15} from "./parse.ts";
16
17const DEFAULT_BLAME_CONCURRENCY = 6;
18
19// Measured on openai/codex with 16 CPUs: 12 blames at once took 964 ms, 16 took 834 ms, and more gained nothing.
20const MAX_BLAME_CONCURRENCY = 16;
21
22const GREP_NO_MATCH = 1;
23
24type Blamed =
25 | { readonly isOk: true; readonly origins: Map<number, Origin> }
26 | { readonly isOk: false; readonly error: string };
27
28export type ScanResult = {
29 readonly scannedAt: number;
30 readonly root: string;
31 readonly userEmail: string | null;
32 readonly issueBase: string | null;
33 readonly todos: readonly Todo[];
34 readonly failures: readonly string[];
35};
36
37export type RunResult = {
38 readonly exitCode: number;
39 readonly stdout: string;
40 readonly stderr: string;
41 readonly isStdoutTruncated: boolean;
42};
43
44type Grepped = { readonly hits: readonly GrepHit[]; readonly isTruncated: boolean };
45
46export type Located = { readonly root: string; readonly path: string };
47
48// An untracked file, or a repository with no commit yet, has no history: its lines count as uncommitted.
49const NO_HISTORY = /no such path|no such ref/u;
50
51/** What a scan needs from the machine; the hooks module builds it from `$`, which cannot cross an import. */
52export type Host = {
53 readonly run: (argv: readonly string[], cwd: string) => Promise<RunResult>;
54 readonly exists: (path: string) => Promise<boolean>;
55 readonly read: (path: string) => Promise<string>;
56};
57
58export async function repoRoot(host: Host, cwd: string): Promise<string | null> {
59 const run = await host.run(["git", "rev-parse", "--show-toplevel"], cwd);
60
61 return run.exitCode === 0 ? run.stdout.trim() : null;
62}
63
64/**
65 * Where an edited file sits, as git names it: asked from the file's own directory, so a symlinked
66 * path or a Windows path with backslashes still lands on the repository-relative path.
67 */
68export async function locate(host: Host, filePath: string): Promise<Located | null> {
69 const slash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
70
71 if (slash < 0) return null;
72
73 const run = await host
74 .run(["git", "rev-parse", "--show-toplevel", "--show-prefix"], filePath.slice(0, slash) || "/")
75 .catch(() => null);
76
77 const [root, prefix] = run?.exitCode === 0 ? run.stdout.split("\n") : [];
78
79 if (root === undefined || root === "" || prefix === undefined) return null;
80
81 return { root, path: `${prefix}${filePath.slice(slash + 1)}` };
82}
83
84export async function isExcludedPath(host: Host, root: string, path: string): Promise<boolean> {
85 const run = await host.run(
86 ["git", "check-attr", "-z", ...EXCLUDING_ATTRIBUTE_NAMES, "--", path],
87 root,
88 );
89
90 if (run.exitCode !== 0) throw new Error(`git check-attr ${path} failed: ${run.stderr.trim()}`);
91
92 return isExcludedByAttributes(run.stdout);
93}
94
95async function grepComments(
96 host: Host,
97 root: string,
98 markers: readonly string[],
99): Promise<Grepped> {
100 if (markers.length === 0) return { hits: [], isTruncated: false };
101
102 const run = await host.run(
103 [
104 "git",
105 "grep",
106 // A configured submodule.recurse makes git refuse --untracked.
107 "--no-recurse-submodules",
108 "--untracked",
109 // color.ui=always would wrap each marker in escape codes.
110 "--no-color",
111 "-z",
112 "-n",
113 "-I",
114 "-F",
115 ...markers.flatMap((marker) => ["-e", marker]),
116 "--",
117 ".",
118 ...SKIPPED_EXTENSIONS.map((extension) => `:(exclude,icase)*.${extension}`),
119 ...ATTRIBUTE_PATHSPECS,
120 ],
121 root,
122 );
123
124 if (run.exitCode === GREP_NO_MATCH) return { hits: [], isTruncated: false };
125
126 if (run.exitCode !== 0) throw new Error(`git grep failed: ${run.stderr.trim()}`);
127
128 return { hits: parseGrep(run.stdout, markers), isTruncated: run.isStdoutTruncated };
129}
130
131async function blame(
132 host: Host,
133 root: string,
134 path: string,
135 lines: readonly number[] | null,
136): Promise<Blamed> {
137 const ranges = lines === null ? [] : lines.flatMap((line) => ["-L", `${line},${line}`]);
138 let run: RunResult;
139
140 try {
141 run = await host.run(["git", "blame", "--line-porcelain", ...ranges, "--", path], root);
142 } catch (error) {
143 return {
144 isOk: false,
145 error: `git blame ${path}: ${error instanceof Error ? error.message : String(error)}`,
146 };
147 }
148
149 if (run.exitCode === 0) return { isOk: true, origins: parseBlame(run.stdout) };
150
151 if (NO_HISTORY.test(run.stderr)) return { isOk: true, origins: new Map() };
152
153 return { isOk: false, error: `git blame ${path}: ${run.stderr.trim()}` };
154}
155
156async function mapPool<T, R>(
157 items: readonly T[],
158 limit: number,
159 work: (item: T) => Promise<R>,
160): Promise<R[]> {
161 const results: R[] = [];
162 let next = 0;
163
164 const worker = async (): Promise<void> => {
165 for (let index = next++; index < items.length; index = next++) {
166 const item = items[index];
167
168 if (item !== undefined) results[index] = await work(item);
169 }
170 };
171
172 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
173
174 return results;
175}
176
177// A hooks module has no `os`: getconf counts the CPUs on Linux and macOS, and Windows, without it, keeps the default.
178async function blameConcurrency(host: Host, root: string): Promise<number> {
179 const run = await host.run(["getconf", "_NPROCESSORS_ONLN"], root).catch(() => null);
180 const count = run?.exitCode === 0 ? Number.parseInt(run.stdout, 10) : Number.NaN;
181
182 return Number.isInteger(count) && count > 0
183 ? Math.min(count, MAX_BLAME_CONCURRENCY)
184 : DEFAULT_BLAME_CONCURRENCY;
185}
186
187/** One line of git output, or null when git exits non-zero: an unset `user.email`, no `origin` remote. */
188async function gitValue(host: Host, root: string, args: readonly string[]): Promise<string | null> {
189 const run = await host.run(["git", ...args], root);
190 const value = run.stdout.trim();
191
192 return run.exitCode === 0 && value !== "" ? value : null;
193}
194
195async function readList(host: Host, root: string): Promise<string | null> {
196 const path = `${root}/${LIST_FILE}`;
197
198 return (await host.exists(path)) ? host.read(path) : null;
199}
200
201export async function scanRepo(
202 host: Host,
203 root: string,
204 markers: readonly string[],
205 scannedAt: number,
206): Promise<ScanResult> {
207 const [{ hits, isTruncated }, listText, userEmail, remote, concurrency] = await Promise.all([
208 grepComments(host, root, markers),
209 readList(host, root),
210 gitValue(host, root, ["config", "user.email"]),
211 gitValue(host, root, ["remote", "get-url", "origin"]),
212 blameConcurrency(host, root),
213 ]);
214
215 const listItems = listText === null ? [] : parseList(listText);
216
217 const linesByPath = new Map<string, number[]>();
218
219 for (const hit of hits)
220 linesByPath.set(hit.path, [...(linesByPath.get(hit.path) ?? []), hit.line]);
221
222 const jobs: { readonly path: string; readonly lines: readonly number[] | null }[] = [
223 ...[...linesByPath].map(([path, lines]) => ({ path, lines })),
224 ...(listItems.length > 0 ? [{ path: LIST_FILE, lines: null }] : []),
225 ];
226
227 const blamed = await mapPool(jobs, concurrency, (job) => blame(host, root, job.path, job.lines));
228
229 const originsByPath = new Map<string, Map<number, Origin>>();
230 const failures: string[] = isTruncated ? ["git grep printed over 4 MiB: the list is cut"] : [];
231 jobs.forEach((job, index) => {
232 const result = blamed[index];
233
234 if (result?.isOk === true) originsByPath.set(job.path, result.origins);
235 else if (result !== undefined) failures.push(result.error);
236 });
237
238 const originOf = (path: string, line: number): Origin =>
239 originsByPath.get(path)?.get(line) ?? {
240 commit: null,
241 authoredAt: scannedAt,
242 authorEmail: null,
243 };
244
245 const todos: Todo[] = [
246 ...listItems.map((item): Todo => ({
247 source: "list",
248 path: LIST_FILE,
249 line: item.line,
250 marker: "TODO",
251 tag: null,
252 text: item.text,
253 ...originOf(LIST_FILE, item.line),
254 })),
255 ...hits.map((hit): Todo => ({ source: "comment", ...hit, ...originOf(hit.path, hit.line) })),
256 ];
257
258 return {
259 scannedAt,
260 root,
261 userEmail,
262 issueBase: remote === null ? null : githubWebUrl(remote),
263 todos: sortNewestFirst(todos),
264 failures,
265 };
266}
267