SLOPSHOPPER

todos

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…

newbandguardcommandtoastprompt
★ 5v1.3.0MITupdated 2026-10-06bengous/claude-code-plugins/todos
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todos
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /todos ⎿ todos: No TODOs in this repository. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

todos

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 ]                              │
╰─────────────────────────────────────────────────────────────────────────────────────────────────╯

Install

/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.

What counts as a TODO

  • 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.
  • Code comments whose first word is a marker (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

Order and age

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.

The band

  • It opens when an interactive session starts in a git repository that has TODOs. The scan runs after the session starts, so it never holds your first prompt.
  • It hides on your next message, or on Hide. /todos scans again and reopens it.
  • It lists 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.
  • The ○ 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.
  • Mine keeps the TODOs whose git author is your user.email, uncommitted lines included. All shows every one again.
  • TODO(#42) links to issue 42 when origin is on GitHub.
  • It keeps what other mods draw in the band below its own list.

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.

Limits

  • A string is recognized within one line only: a # TODO inside a multi-line string, such as a Python """ block, is listed.
  • Every 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.
  • A title or comment longer than 200 characters is cut, and control characters are dropped.
  • The list scrolls through the 500 newest TODOs, and as many of yours; the footer counts the others as +N more.
  • While the arrows show, an 8 or 9 typed alone into an empty prompt, then a pause, pages the list: it does not start your message.
  • A pick names a TODO by its line: a scan that moves the line, after an edit or /todos, drops the pick.

Settings

Claude Code asks for them when you enable the plugin; they are also rows in /config.

SettingDefaultWhat it does
show_on_starttrueOpen the band when a session starts. /todos opens it either way.
rows8How many TODOs the band shows at once, the rest a scroll away; it shows fewer in a short terminal.
markersTODO,FIXME,HACKThe comment words that mark a TODO, separated by commas.
mine_onlyfalseStart each session with Mine on.

Files

PathWhat it holds
hooks/register.tsThe hooks: session start, prompt, /todos, the edit toast, the band
hooks/scan.tsThe scan: git grep, TODO.md, git blame, through a host the hooks build from $
hooks/parse.tsPure parsing and formatting, covered by bun test
hooks/register.test.tsTests for claude plugin test, run in Claude Code's own test kit
Source 4 files
hooks/register.ts 522 lines
1import 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};
522
types/index.d.ts 37 lines
1export 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}
37
hooks/parse.ts 385 lines
1import 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}
385
hooks/scan.ts 267 lines
1import 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