Native TaskLedger summary and interactive task board 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.
tl 0.9.0 or newer on PATH.tl/, or initialize one with /tl-init/tl-board pane with Focused and All viewstl show.tl/| Command | Action |
|---|---|
/tl-board | Open the interactive board |
/tl-refresh | Reload summary and board data |
/tl-toggle | Hide or show the summary for this session |
/tl-init | Initialize .tl/ after confirmation |
/tl-capture <rough todos> | Ask Claude to refine rough todos into tasks |
/tl-triage | Ask 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.
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.
hooks/register.ts 567 lines1import 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}
567hooks/model.ts 173 lines1export 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