SLOPSHOPPER

tl

Native TaskLedger summary and interactive task board for Claude Code.

newpanebandcommandtoaststatus
v0.1.5MITupdated 2026-10-03aholbreich/claude-mods/plugins/tl
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tl
│ ┃ tl-board ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Task Ledger Board · 0 focused ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ a: Show all u: Refresh q: Close ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No tasks in this view. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ↑/↓ navigate · Enter select · q or Esc │ ┃ close ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tl-board │ ⎿ tl: The installed tl version (unknown) is incompatible; 0.9.0+ i │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ tl: tl version unknown is incompatible; need 0.9.0+

Draws

Pane · tl-board
Task Ledger Board · 0 focused a: Show all u: Refresh q: Close No tasks in this view. ↑/↓ navigate · Enter select · q or Esc close
README

TaskLedger mod for Claude Code

A native Claude Code integration for tl, the Git-native TaskLedger CLI. It shells out to the separately installed tl executable and keeps .tl/ as the source of truth.

Requirements

  • Claude Code 2.1.287 or newer
  • tl 0.9.0 or newer on PATH
  • A repository containing .tl/, or initialize one with /tl-init

Features

  • Compact ready, active, blocked, pending, and stale summary above the prompt
  • Start, refine, review, or plan a task straight from the summary with hotkeys
  • Interactive /tl-board pane with Focused and All views
  • Task details via tl show
  • Board actions to implement, refine, review, or plan a selected task with Claude
  • Confirmed cancel/remove lifecycle actions
  • Automatic refresh after each main-agent turn
  • TaskLedger workflow context added only when the repository contains .tl/

Commands

CommandAction
/tl-boardOpen the interactive board
/tl-refreshReload summary and board data
/tl-toggleHide or show the summary for this session
/tl-initInitialize .tl/ after confirmation
/tl-capture <rough todos>Ask Claude to refine rough todos into tasks
/tl-triageAsk Claude to review ledger health without mutating it

In the summary above the prompt, press ctrl+x tab to move the keyboard into it, ↑/↓ to select a task, then i, r, v, or p to implement, refine, review, or plan it; with no row selected they act on the first task. Focus moves only over task rows, wrapping at either end. Enter on a task opens it in the board, and Esc returns to the prompt.

Inside the board, use ↑/↓ and Enter to navigate controls. In task details, i, r, v, and p start implementation, refinement, review, and planning; c and x cancel or remove after confirmation. b goes back to the list, and q or Esc closes the board.

Development

From the marketplace repository root:

claude --plugin-dir ./plugins/tl
claude plugin validate ./plugins/tl --strict
claude plugin test ./plugins/tl

The mod uses only Claude Code's mods API ($.process, $.fs, $.ui, $.prompt, and $.command) and executes tl with argv arrays rather than a shell.

Source 2 files
hooks/register.ts 567 lines
1import type { EngineInterface, ProcessRunResult, Register } from "claude-code";
2import {
3  EMPTY_SNAPSHOT,
4  MIN_TL_VERSION,
5  TASK_LEDGER_CONTEXT,
6  boardSections,
7  buildCapturePrompt,
8  buildTaskWorkflowPrompt,
9  buildTriagePrompt,
10  focusedSections,
11  isTlVersionCompatible,
12  parseTlVersion,
13  snapshotSections,
14  taskId,
15  taskLabel,
16  taskText,
17  tasksFromJson,
18  truncate,
19  type Snapshot,
20  type TaskSection,
21  type TaskSummary,
22  type WorkflowAction,
23} from "./model";
24
25const PANE_ID = "tl-board";
26const PANE_TITLE = "Task Ledger";
27const MAX_BAND_TASKS_PER_SECTION = 3;
28const MAX_BAND_TASK_ROWS = 9;
29const BAND_TASK_KEY_PREFIX = "tl-band-task-";
30const BAND_ACTION_KEY_PREFIX = "tl-band-action-";
31const WORKFLOW_HOTKEYS: ReadonlyArray<{ action: WorkflowAction; label: string; hotkey: string }> = [
32  { action: "implement", label: "Implement", hotkey: "i" },
33  { action: "refine", label: "Refine", hotkey: "r" },
34  { action: "review", label: "Review", hotkey: "v" },
35  { action: "plan", label: "Plan", hotkey: "p" },
36];
37
38type EnvironmentState =
39  | { kind: "loading" }
40  | { kind: "ready"; version: string }
41  | { kind: "not-found" }
42  | { kind: "incompatible"; version: string | null }
43  | { kind: "no-ledger" }
44  | { kind: "error"; message: string };
45
46type BoardMode = "list" | "details";
47
48let environment: EnvironmentState = { kind: "loading" };
49let snapshot: Snapshot = EMPTY_SNAPSHOT;
50let sections: TaskSection[] = [];
51let boardMode: BoardMode = "list";
52let detailsTask: TaskSummary | null = null;
53let detailsText = "";
54let detailsLoading = false;
55let boardLoading = false;
56let boardError: string | null = null;
57let showAll = false;
58let bandHidden = false;
59let bandSelectedId: string | null = null;
60let bandRowIds: string[] = [];
61let refreshSerial = 0;
62
63export const register: Register = (on) => {
64  on("session.start", async ($, e, next) => {
65    await registerCommands($);
66    const result = await next(e);
67    await refreshSummary($);
68    return result;
69  });
70
71  on("turn.complete", async ($, e, next) => {
72    const result = await next(e);
73    if (!e.agentId) await refreshSummary($);
74    return result;
75  });
76
77  on("prompt.context", async ($, e, next) => {
78    const result = await next(e);
79    try {
80      if (!(await $.fs.exists(".tl"))) return result;
81      if (result.blocks.some((block) => block.name === "taskLedger")) return result;
82      return {
83        ...result,
84        blocks: [...result.blocks, { name: "taskLedger", text: TASK_LEDGER_CONTEXT }],
85      };
86    } catch {
87      return result;
88    }
89  });
90
91  on("command.run", { command: "tl-board" }, async ($) => {
92    await refreshSummary($);
93    if (environment.kind !== "ready") return { text: environmentMessage(environment) };
94    await openBoard($);
95    return {};
96  });
97
98  on("ui.focus", { component: "AbovePrompt" }, async ($, e, next) => {
99    const element = e.element;
100    if (element?.startsWith(BAND_TASK_KEY_PREFIX)) {
101      bandSelectedId = element.slice(BAND_TASK_KEY_PREFIX.length);
102      return next(e);
103    }
104    // The workflow buttons exist only for their hotkeys, so the ring skips them and wraps around the task rows.
105    if (element?.startsWith(BAND_ACTION_KEY_PREFIX) && bandRowIds.length > 0) {
106      const index = bandSelectedId ? bandRowIds.indexOf(bandSelectedId) : -1;
107      const last = bandRowIds.length - 1;
108      const target = (index === last ? bandRowIds[0] : index === 0 ? bandRowIds[last] : bandRowIds[Math.max(index, 0)]) ?? null;
109      if (!target) return next(e);
110      bandSelectedId = target;
111      return next({ ...e, element: `${BAND_TASK_KEY_PREFIX}${target}` });
112    }
113    return next(e);
114  });
115
116  on("command.run", { command: "tl-refresh" }, async ($) => {
117    await refreshSummary($);
118    if (environment.kind === "ready") await loadBoard($);
119    return { text: summaryMessage() };
120  });
121
122  on("command.run", { command: "tl-toggle" }, async ($) => {
123    bandHidden = !bandHidden;
124    if (!bandHidden) await refreshSummary($);
125    $.ui.invalidate("ui.render");
126    return { text: `Task Ledger summary ${bandHidden ? "hidden" : "shown"}.` };
127  });
128
129  on("command.run", { command: "tl-init" }, async ($) => {
130    const probe = await probeEnvironment($, false);
131    if (probe.kind === "not-found") return { text: "The tl CLI is not available on PATH." };
132    if (await $.fs.exists(".tl")) return { text: "Task ledger is already initialized in this repository." };
133
134    const answer = await askSafely($, "Initialize TaskLedger in this repository?", ["Initialize", "Cancel"]);
135    if (answer !== "Initialize") return { text: "TaskLedger initialization cancelled." };
136    const run = await runTl($, ["init"]);
137    await refreshSummary($);
138    if (run.exitCode !== 0) return { text: run.stderr.trim() || `tl init failed with exit code ${run.exitCode}.` };
139    return { text: run.stdout.trim() || "Initialized TaskLedger." };
140  });
141
142  on("command.run", { command: "tl-capture" }, async ($, e) => {
143    const todos = e.args.trim();
144    if (!todos) return { text: "Usage: /tl-capture <rough todos>" };
145    submitAfterCommand($, buildCapturePrompt(todos));
146    return { text: "Asking Claude to turn these todos into tasks." };
147  });
148
149  on("command.run", { command: "tl-triage" }, async ($) => {
150    if (!(await $.fs.exists(".tl"))) return { text: "No task ledger found. Run /tl-init first." };
151    submitAfterCommand($, buildTriagePrompt());
152    return { text: "Asking Claude to triage the task ledger." };
153  });
154
155  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
156    if (e.props.hasSurvey || bandHidden || environment.kind !== "ready" || !hasSnapshotContent(snapshot)) {
157      return next(e);
158    }
159
160    const downstream = await next(e);
161    const { Box, Text, Button } = $.ui.resolve(e);
162    const nonEmpty = snapshotSections(snapshot).filter((section) => section.tasks.length > 0);
163    const summary = nonEmpty.map((section) => `${section.icon}${section.tasks.length} ${section.label.toLowerCase()}`).join(" · ");
164    const rows = [];
165    const selectableIds: string[] = [];
166    let taskRows = 0;
167
168    for (const section of nonEmpty) {
169      const visible = section.tasks.slice(0, MAX_BAND_TASKS_PER_SECTION);
170      for (const task of visible) {
171        if (taskRows >= MAX_BAND_TASK_ROWS) break;
172        const id = taskId(task);
173        if (id && !selectableIds.includes(id)) {
174          selectableIds.push(id);
175          rows.push(Box({
176            flexDirection: "row",
177            children: [
178              Text({ color: section.color, children: `${section.icon} ` }),
179              Button({
180                key: `${BAND_TASK_KEY_PREFIX}${id}`,
181                label: truncate(taskText(task), e.props.bodyColumns - 4),
182                plain: true,
183                onPress: async () => openBoardFromBand($, task),
184              }),
185            ],
186          }));
187        } else {
188          rows.push(Text({ color: section.color, wrap: "truncate-end", children: taskLabel(task, section.icon) }));
189        }
190        taskRows += 1;
191      }
192      const remaining = section.tasks.length - visible.length;
193      if (remaining > 0 && taskRows < MAX_BAND_TASK_ROWS) {
194        rows.push(Text({ dimColor: true, children: `  ${remaining} more ${section.label.toLowerCase()}` }));
195        taskRows += 1;
196      }
197      if (taskRows >= MAX_BAND_TASK_ROWS) break;
198    }
199
200    bandRowIds = selectableIds;
201    const band = Box({
202      flexDirection: "column",
203      paddingX: 1,
204      children: [
205        Text({ color: "cyan", bold: true, children: `● Task Ledger  ${summary}` }),
206        ...rows,
207        Box({
208          flexDirection: "row",
209          flexWrap: "wrap",
210          columnGap: 2,
211          children: [
212            ...(selectableIds.length > 0
213              ? WORKFLOW_HOTKEYS.map(({ action, label, hotkey }) => Button({
214                key: `${BAND_ACTION_KEY_PREFIX}${action}`,
215                label,
216                hotkey,
217                plain: true,
218                dimColor: true,
219                onPress: () => startBandWorkflow($, selectableIds, action),
220              }))
221              : []),
222            Text({ dimColor: true, children: "ctrl+x tab to select · /tl-board · /tl-toggle" }),
223          ],
224        }),
225      ],
226    });
227    return Box({
228      flexDirection: "column",
229      children: [...(downstream ? [downstream] : []), band],
230    });
231  });
232
233  on("ui.render", { component: "Pane" }, async ($, e, next) => {
234    if (e.requestId !== PANE_ID) return next(e);
235    const { Box, Text, Button } = $.ui.resolve(e);
236    const redraw = () => $.ui.invalidate("ui.render");
237
238    if (boardLoading) {
239      return Box({ padding: 1, children: [Text({ color: "yellow", children: "Loading TaskLedger…" })] });
240    }
241
242    if (boardError) {
243      return Box({
244        flexDirection: "column",
245        padding: 1,
246        rowGap: 1,
247        children: [
248          Text({ color: "red", bold: true, children: "TaskLedger unavailable" }),
249          Text({ wrap: "wrap", children: boardError }),
250          Button({ key: "retry", label: "u: Retry", hotkey: "u", onPress: async () => loadBoard($) }),
251        ],
252      });
253    }
254
255    if (boardMode === "details" && detailsTask) {
256      const id = taskId(detailsTask);
257      const action = (workflow: WorkflowAction) => async () => {
258        if (!id) return;
259        await $.ui.close({ id: PANE_ID });
260        void $.prompt.submit({ text: buildTaskWorkflowPrompt(id, workflow) });
261      };
262      return Box({
263        flexDirection: "column",
264        padding: 1,
265        rowGap: 1,
266        children: [
267          Text({ color: "cyan", bold: true, children: taskLabel(detailsTask) }),
268          Text({ color: detailsLoading ? "yellow" : undefined, wrap: "wrap", children: detailsText || "No task details." }),
269          Box({
270            flexDirection: "row",
271            flexWrap: "wrap",
272            columnGap: 2,
273            children: [
274              Button({ key: "back", label: "Back", hotkey: "b", plain: true, autoFocus: true, onPress: () => {
275                boardMode = "list";
276                redraw();
277              } }),
278              Button({ key: "implement", label: "Implement", hotkey: "i", plain: true, onPress: action("implement") }),
279              Button({ key: "refine", label: "Refine", hotkey: "r", plain: true, onPress: action("refine") }),
280              Button({ key: "review", label: "Review", hotkey: "v", plain: true, onPress: action("review") }),
281              Button({ key: "plan", label: "Plan", hotkey: "p", plain: true, onPress: action("plan") }),
282              ...(id ? [
283                Button({ key: "cancel", label: "Cancel task", hotkey: "c", plain: true, onPress: async () => lifecycleTask($, "cancel", id) }),
284                Button({ key: "remove", label: "Remove task", hotkey: "x", plain: true, onPress: async () => lifecycleTask($, "remove", id) }),
285              ] : []),
286              Button({ key: "close", label: "Close", hotkey: "q", plain: true, role: "dismiss", onPress: async () => $.ui.close({ id: PANE_ID }) }),
287            ],
288          }),
289          Text({ dimColor: true, children: "b back to list · q or Esc close" }),
290        ],
291      });
292    }
293
294    const visibleSections = focusedSections(sections, showAll);
295    const taskCount = visibleSections.reduce((sum, section) => sum + section.tasks.length, 0);
296    let firstTask = true;
297    const sectionTrees = visibleSections.flatMap((section) => {
298      if (section.tasks.length === 0) return [];
299      const buttons = section.tasks.flatMap((task) => {
300        const id = taskId(task);
301        if (!id) return [];
302        const autofocus = firstTask;
303        firstTask = false;
304        return [Button({
305          key: `task-${id}`,
306          label: taskLabel(task, section.icon),
307          plain: true,
308          dimColor: section.label === "Done" || section.label === "Cancelled",
309          ...(autofocus ? { autoFocus: true as const } : {}),
310          onPress: async () => showTaskDetails($, task),
311        })];
312      });
313      return [
314        Text({ color: section.color, bold: true, children: `${section.icon} ${section.label} (${section.tasks.length})` }),
315        ...buttons,
316      ];
317    });
318
319    return Box({
320      flexDirection: "column",
321      padding: 1,
322      rowGap: 1,
323      children: [
324        Text({ color: "cyan", bold: true, children: `Task Ledger Board · ${taskCount} ${showAll ? "total" : "focused"}` }),
325        Box({
326          flexDirection: "row",
327          columnGap: 2,
328          children: [
329            Button({ key: "toggle-all", label: showAll ? "Focused" : "Show all", hotkey: "a", plain: true, onPress: () => {
330              showAll = !showAll;
331              redraw();
332            } }),
333            Button({ key: "refresh", label: "Refresh", hotkey: "u", plain: true, onPress: async () => loadBoard($) }),
334            Button({ key: "close", label: "Close", hotkey: "q", plain: true, role: "dismiss", onPress: async () => $.ui.close({ id: PANE_ID }) }),
335          ],
336        }),
337        ...(sectionTrees.length > 0 ? sectionTrees : [Text({ dimColor: true, children: "No tasks in this view." })]),
338        Text({ dimColor: true, children: "↑/↓ navigate · Enter select · q or Esc close" }),
339      ],
340    });
341  });
342};
343
344async function registerCommands($: EngineInterface): Promise<void> {
345  const commands = [
346    { name: "tl-board", description: "Open the interactive TaskLedger board", immediate: true },
347    { name: "tl-refresh", description: "Refresh TaskLedger data", immediate: true },
348    { name: "tl-toggle", description: "Hide or show the TaskLedger summary", immediate: true },
349    { name: "tl-init", description: "Initialize TaskLedger in this repository", immediate: true },
350    { name: "tl-capture", description: "Capture rough todos and ask Claude to refine them", argumentHint: "<rough todos>" },
351    { name: "tl-triage", description: "Ask Claude to triage the task ledger" },
352  ] as const;
353  for (const command of commands) {
354    try {
355      await $.command.register(command);
356    } catch (error) {
357      $.ui.log(`Could not register /${command.name}: ${messageOf(error)}`, { to: "debug" });
358    }
359  }
360}
361
362async function runTl($: EngineInterface, args: string[], timeoutMs = 30_000): Promise<ProcessRunResult> {
363  return $.process.run(["tl", "--color", "never", ...args], { timeoutMs });
364}
365
366async function probeEnvironment($: EngineInterface, requireLedger = true): Promise<EnvironmentState> {
367  let versionRun: ProcessRunResult;
368  try {
369    versionRun = await runTl($, ["--version"], 2_000);
370  } catch {
371    return { kind: "not-found" };
372  }
373  if (versionRun.exitCode !== 0) return { kind: "not-found" };
374
375  const version = parseTlVersion(versionRun.stdout);
376  if (!version || !isTlVersionCompatible(version)) return { kind: "incompatible", version };
377  if (requireLedger && !(await $.fs.exists(".tl"))) return { kind: "no-ledger" };
378  return { kind: "ready", version };
379}
380
381async function listTasks($: EngineInterface, args: string[]): Promise<TaskSummary[]> {
382  const run = await runTl($, args, 10_000);
383  const command = `tl ${args.join(" ")}`;
384  if (run.exitCode !== 0) throw new Error(run.stderr.trim() || run.stdout.trim() || `${command} failed with exit code ${run.exitCode}`);
385  if (run.isStdoutTruncated) throw new Error(`${command} output exceeded the process output limit`);
386  return tasksFromJson(run.stdout, command);
387}
388
389async function refreshSummary($: EngineInterface): Promise<void> {
390  const serial = ++refreshSerial;
391  try {
392    const nextEnvironment = await probeEnvironment($);
393    if (serial !== refreshSerial) return;
394    environment = nextEnvironment;
395    if (nextEnvironment.kind !== "ready") {
396      snapshot = EMPTY_SNAPSHOT;
397      updateStatus($);
398      $.ui.invalidate("ui.render");
399      return;
400    }
401
402    const [ready, inProgress, blocked, pendingHuman, stale] = await Promise.all([
403      listTasks($, ["ready", "--json"]),
404      listTasks($, ["list", "--status", "in_progress", "--json"]),
405      listTasks($, ["list", "--status", "blocked", "--json"]),
406      listTasks($, ["list", "--status", "pending_human", "--json"]),
407      listTasks($, ["stale", "--json"]),
408    ]);
409    if (serial !== refreshSerial) return;
410    snapshot = { ready, inProgress, blocked, pendingHuman, stale };
411    updateStatus($);
412  } catch (error) {
413    if (serial !== refreshSerial) return;
414    environment = { kind: "error", message: messageOf(error) };
415    snapshot = EMPTY_SNAPSHOT;
416    updateStatus($);
417  }
418  $.ui.invalidate("ui.render");
419}
420
421async function loadBoard($: EngineInterface): Promise<void> {
422  boardLoading = true;
423  boardError = null;
424  $.ui.invalidate("ui.render");
425  try {
426    const [inventory, ready, stale] = await Promise.all([
427      listTasks($, ["list", "--all", "--json"]),
428      listTasks($, ["ready", "--json"]),
429      listTasks($, ["stale", "--json"]),
430    ]);
431    sections = boardSections(inventory, ready, stale);
432  } catch (error) {
433    boardError = messageOf(error);
434  } finally {
435    boardLoading = false;
436    $.ui.invalidate("ui.render");
437  }
438}
439
440async function openBoard($: EngineInterface, task?: TaskSummary): Promise<void> {
441  await loadBoard($);
442  boardMode = "list";
443  showAll = prefersAllView(sections);
444  if (task) await showTaskDetails($, task);
445  await $.ui.open({
446    id: PANE_ID,
447    title: PANE_TITLE,
448    focus: true,
449    closeOnEscape: true,
450    holdToasts: true,
451    rows: 24,
452    columns: 76,
453  });
454}
455
456async function openBoardFromBand($: EngineInterface, task: TaskSummary): Promise<void> {
457  try {
458    await openBoard($, task);
459  } catch (error) {
460    $.ui.toast(`Could not open the board: ${messageOf(error)}`);
461  }
462}
463
464// The band's workflow hotkeys act on the row last focused with ctrl+x tab and the arrows, else the first row.
465function startBandWorkflow($: EngineInterface, selectableIds: readonly string[], action: WorkflowAction): void {
466  const id = bandSelectedId && selectableIds.includes(bandSelectedId) ? bandSelectedId : selectableIds[0];
467  if (!id) return;
468  $.prompt.submit({ text: buildTaskWorkflowPrompt(id, action) }).catch((error) => $.ui.toast(`Could not submit prompt: ${messageOf(error)}`));
469}
470
471async function showTaskDetails($: EngineInterface, task: TaskSummary): Promise<void> {
472  const id = taskId(task);
473  if (!id) return;
474  detailsTask = task;
475  detailsLoading = true;
476  detailsText = "Loading task details…";
477  boardMode = "details";
478  $.ui.invalidate("ui.render");
479  try {
480    const run = await runTl($, ["show", id]);
481    detailsText = run.stdout.trim() || run.stderr.trim() || `tl show ${id} exited with ${run.exitCode}`;
482  } catch (error) {
483    detailsText = messageOf(error);
484  } finally {
485    detailsLoading = false;
486    $.ui.invalidate("ui.render");
487  }
488}
489
490async function lifecycleTask($: EngineInterface, action: "cancel" | "remove", id: string): Promise<void> {
491  const verb = action === "cancel" ? "Cancel" : "Remove permanently";
492  const answer = await askSafely($, `${verb} ${id}?`, [verb, "Keep task"]);
493  if (answer !== verb) return;
494  const reasonAnswer = await askSafely($, `Why ${action} ${id}: use the default reason, or type your own under Other?`, ["Use default reason", "Keep task"]);
495  if (!reasonAnswer || reasonAnswer === "Keep task") return;
496  const defaultReason = action === "cancel" ? "cancelled from Claude TaskLedger board" : "removed from Claude TaskLedger board";
497  const reason = reasonAnswer === "Use default reason" ? defaultReason : reasonAnswer.trim() || defaultReason;
498  const args = [action, id, "--message", reason];
499  if (action === "remove") args.push("--force");
500  try {
501    const run = await runTl($, args);
502    if (run.exitCode !== 0) {
503      $.ui.toast(run.stderr.trim() || `${verb} failed for ${id}.`);
504      return;
505    }
506    boardMode = "list";
507    detailsTask = null;
508    $.ui.toast(`${action === "cancel" ? "Cancelled" : "Removed"} ${id}.`);
509    await Promise.all([loadBoard($), refreshSummary($)]);
510  } catch (error) {
511    $.ui.toast(`${verb} failed: ${messageOf(error)}`);
512  }
513}
514
515// A command.run hook holds the turn that a prompt submitted from it would wait on, so submit once it returns.
516function submitAfterCommand($: EngineInterface, text: string): void {
517  $.clock.after(0, () => {
518    $.prompt.submit({ text }).catch((error) => $.ui.toast(`Could not submit prompt: ${messageOf(error)}`));
519  });
520}
521
522async function askSafely($: EngineInterface, question: string, options?: readonly string[]): Promise<string | null> {
523  try {
524    return await $.ui.ask(question, options);
525  } catch {
526    return null;
527  }
528}
529
530function updateStatus($: EngineInterface): void {
531  if (environment.kind === "not-found") $.ui.status("tl CLI not found");
532  else if (environment.kind === "incompatible") $.ui.status(`tl ${environment.version ?? "version unknown"} is incompatible; need ${MIN_TL_VERSION}+`);
533  else if (environment.kind === "error") $.ui.status(`tl error: ${environment.message}`);
534  else $.ui.status(undefined);
535}
536
537function environmentMessage(state: EnvironmentState): string {
538  if (state.kind === "not-found") return "The tl CLI is not available on PATH.";
539  if (state.kind === "incompatible") return `The installed tl version (${state.version ?? "unknown"}) is incompatible; ${MIN_TL_VERSION}+ is required.`;
540  if (state.kind === "no-ledger") return "No task ledger found in this repository. Run /tl-init first.";
541  if (state.kind === "error") return `TaskLedger error: ${state.message}`;
542  if (state.kind === "loading") return "TaskLedger is still loading.";
543  return `TaskLedger ${state.version} is ready.`;
544}
545
546function summaryMessage(): string {
547  if (environment.kind !== "ready") return environmentMessage(environment);
548  const parts = snapshotSections(snapshot)
549    .filter((section) => section.tasks.length > 0)
550    .map((section) => `${section.tasks.length} ${section.label.toLowerCase()}`);
551  return parts.length > 0 ? `TaskLedger refreshed: ${parts.join(", ")}.` : "TaskLedger refreshed: no actionable tasks.";
552}
553
554function prefersAllView(value: TaskSection[]): boolean {
555  const focusedCount = focusedSections(value, false).reduce((sum, section) => sum + section.tasks.length, 0);
556  const allCount = value.reduce((sum, section) => sum + section.tasks.length, 0);
557  return focusedCount <= 1 && allCount > focusedCount;
558}
559
560function hasSnapshotContent(value: Snapshot): boolean {
561  return snapshotSections(value).some((section) => section.tasks.length > 0);
562}
563
564function messageOf(error: unknown): string {
565  return error instanceof Error ? error.message : String(error);
566}
567
hooks/model.ts 173 lines
1export const MIN_TL_VERSION = "0.9.0";
2
3export type TaskSummary = {
4  id?: unknown;
5  title?: unknown;
6  status?: unknown;
7  priority?: unknown;
8  tags?: unknown;
9};
10
11export type TaskSection = {
12  label: string;
13  icon: string;
14  color: string;
15  tasks: TaskSummary[];
16};
17
18export type Snapshot = {
19  ready: TaskSummary[];
20  inProgress: TaskSummary[];
21  blocked: TaskSummary[];
22  pendingHuman: TaskSummary[];
23  stale: TaskSummary[];
24};
25
26export const EMPTY_SNAPSHOT: Snapshot = {
27  ready: [],
28  inProgress: [],
29  blocked: [],
30  pendingHuman: [],
31  stale: [],
32};
33
34export function tasksFromJson(text: string, command: string): TaskSummary[] {
35  if (text.trim() === "") return [];
36  let parsed: unknown;
37  try {
38    parsed = JSON.parse(text);
39  } catch (error) {
40    throw new Error(`${command} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
41  }
42  if (parsed === null) return [];
43  if (!Array.isArray(parsed)) throw new Error(`${command} returned JSON that is not a task list`);
44  return parsed.filter((item): item is TaskSummary => typeof item === "object" && item !== null);
45}
46
47export function parseTlVersion(output: string): string | null {
48  const match = /^(?:tl(?:\s+version)?\s+)?v?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)$/.exec(output.trim());
49  if (!match) return null;
50  if (match[2]?.split(".").some((part) => /^0\d+$/.test(part))) return null;
51  return match[1] ?? null;
52}
53
54export function isTlVersionCompatible(version: string): boolean {
55  const parsed = parseTlVersion(version);
56  if (parsed === null) return false;
57  const [core = "", ...suffixParts] = (parsed.split("+")[0] ?? "").split("-");
58  const suffix = suffixParts.join("-");
59  const installed = core.split(".").map(Number);
60  const minimum = MIN_TL_VERSION.split(".").map(Number);
61  for (let index = 0; index < minimum.length; index += 1) {
62    const have = installed[index] ?? 0;
63    const need = minimum[index] ?? 0;
64    if (have !== need) return have > need;
65  }
66  return suffix === "" || /^(?:0|[1-9]\d*)-[0-9a-f]{4,64}$/i.test(suffix);
67}
68
69export function taskId(task: TaskSummary): string | null {
70  return typeof task.id === "string" && task.id !== "" ? task.id : null;
71}
72
73export function priorityMarker(priority: unknown): string {
74  if (typeof priority !== "string") return "△";
75  if (priority.toLowerCase() === "high") return "▲";
76  if (priority.toLowerCase() === "medium") return "△";
77  return "▽";
78}
79
80export function taskText(task: TaskSummary): string {
81  const id = taskId(task) ?? "unknown";
82  const title = typeof task.title === "string" ? task.title : "(untitled)";
83  return `${id} ${priorityMarker(task.priority)} ${title}`;
84}
85
86export function taskLabel(task: TaskSummary, icon = "·"): string {
87  return `${icon} ${taskText(task)}`;
88}
89
90export function truncate(text: string, columns: number): string {
91  if (columns < 1) return "";
92  const chars = [...text];
93  return chars.length <= columns ? text : `${chars.slice(0, columns - 1).join("")}…`;
94}
95
96export function snapshotSections(snapshot: Snapshot): TaskSection[] {
97  return [
98    { label: "Active", icon: "◐", color: "green", tasks: snapshot.inProgress },
99    { label: "Blocked", icon: "▲", color: "red", tasks: snapshot.blocked },
100    { label: "Pending", icon: "?", color: "yellow", tasks: snapshot.pendingHuman },
101    { label: "Ready", icon: "○", color: "cyan", tasks: snapshot.ready },
102    { label: "Stale", icon: "◇", color: "yellow", tasks: snapshot.stale },
103  ];
104}
105
106export function boardSections(inventory: TaskSummary[], ready: TaskSummary[], stale: TaskSummary[]): TaskSection[] {
107  const sections: TaskSection[] = [
108    { label: "In progress", icon: "◐", color: "green", tasks: [] },
109    { label: "Ready", icon: "○", color: "cyan", tasks: [] },
110    { label: "Waiting", icon: "◌", color: "yellow", tasks: [] },
111    { label: "Blocked", icon: "▲", color: "red", tasks: [] },
112    { label: "Pending human", icon: "?", color: "yellow", tasks: [] },
113    { label: "Stale claims", icon: "◇", color: "yellow", tasks: [] },
114    { label: "Other", icon: "·", color: "gray", tasks: [] },
115    { label: "Done", icon: "✓", color: "green", tasks: [] },
116    { label: "Cancelled", icon: "✗", color: "gray", tasks: [] },
117  ];
118  const byLabel = new Map(sections.map((section) => [section.label, section]));
119  const readyIds = new Set(ready.map(taskId).filter((id): id is string => id !== null));
120  const staleIds = new Set(stale.map(taskId).filter((id): id is string => id !== null));
121  const seen = new Set<string>();
122
123  for (const task of inventory) {
124    const id = taskId(task);
125    if (!id || seen.has(id)) continue;
126    seen.add(id);
127
128    let label = "Other";
129    if (task.status === "in_progress") label = "In progress";
130    else if (task.status === "blocked") label = "Blocked";
131    else if (task.status === "pending_human") label = "Pending human";
132    else if (task.status === "done") label = "Done";
133    else if (task.status === "cancelled") label = "Cancelled";
134
135    if (label !== "Done" && label !== "Cancelled") {
136      if (staleIds.has(id)) label = "Stale claims";
137      else if (task.status === "open") label = readyIds.has(id) ? "Ready" : "Waiting";
138    }
139    byLabel.get(label)?.tasks.push(task);
140  }
141  return sections;
142}
143
144export function focusedSections(sections: TaskSection[], showAll: boolean): TaskSection[] {
145  if (showAll) return sections;
146  return sections.filter((section) => section.label !== "Done" && section.label !== "Cancelled");
147}
148
149export type WorkflowAction = "implement" | "refine" | "review" | "plan";
150
151export function buildTaskWorkflowPrompt(id: string, action: WorkflowAction): string {
152  if (action === "implement") {
153    return `Implement task ${id}.\n\nWorkflow:\n1. Inspect context with tl show ${id} and tl history ${id}.\n2. Claim the task with tl claim ${id} before editing.\n3. Make focused changes for the task.\n4. Run relevant verification.\n5. Add tl note ${id} -m "..." with meaningful progress and test results.\n6. Close with tl close ${id} only when done and verified; otherwise use tl block, tl pending, tl cancel, or tl release as appropriate.`;
154  }
155  if (action === "refine") {
156    return `Refine task ${id}.\n\nReview tl show ${id} and tl history ${id}. Improve its title, description, priority, type, tags, dependencies, or references when useful. Present proposed refinements first if anything is ambiguous. Do not edit repository files for this action.`;
157  }
158  if (action === "review") {
159    return `Review task ${id} for completeness and readiness.\n\nInspect tl show ${id} and tl history ${id}. Look for missing acceptance criteria, blockers, duplicates, unclear scope, or stale context. Summarize findings and recommended next steps. Do not edit files or change the task unless asked.`;
160  }
161  return `Plan implementation for task ${id}.\n\nInspect tl show ${id} and tl history ${id}. Produce a concise implementation plan, likely files or areas to inspect, risks, and verification steps. Do not claim the task or edit files yet.`;
162}
163
164export function buildCapturePrompt(todos: string): string {
165  return `Turn these rough todos into clean tl task-ledger tasks.\n\nDeduplicate overlapping items, split unrelated work, and infer clear titles, descriptions, priorities, types, tags, dependencies, and references. Present the cleaned list for confirmation before creating more than one task. After confirmation, create tasks with the tl CLI and report partial failures. Do not create tasks silently.\n\nRough todos:\n\n${todos}`;
166}
167
168export function buildTriagePrompt(): string {
169  return `Perform a task-ledger triage review.\n\nUse tl list --all --json for the inventory. Inspect relevant tasks with tl show and tl history. Identify duplicates, coverage gaps, vague scope, stale priorities or statuses, actionable blockers, pending-human nudges, and missing dependencies. Report the top issues and propose concrete changes, but do not mutate tasks until I confirm.`;
170}
171
172export const TASK_LEDGER_CONTEXT = `Task ledger is available in this repository through the tl CLI. Before selecting queued work, run tl ready --json. Inspect a task with tl show <id> and tl history <id>, claim it before editing, record meaningful progress with tl note, and finish explicitly with tl close, tl block, tl pending, tl cancel, or tl release. Never edit .tl/events.jsonl manually.`;
173