Files
shockbot/utils/todoTracking.ts
T
Colin McDonnell 18c8d34da6 remove task list from review bodies; keep in progress comments only (#542)
review bodies were embedding a task-list snapshot that could capture
stale in-progress state due to timing between the agent's final
TodoWrite and the review submission API call. progress comments
are the authoritative checklist surface — remove the review-body
embedding entirely so there is a single source of truth.

also adds a `completeInProgress` option to `renderCollapsible` so
the progress-comment path can finalize any in-progress items at
render time without mutating tracker state.

Made-with: Cursor
2026-04-15 20:31:15 +00:00

168 lines
5.3 KiB
TypeScript

import { log } from "./log.ts";
type TodoItem = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
};
function isValidTodoStatus(value: string): value is TodoItem["status"] {
return (
value === "pending" || value === "in_progress" || value === "completed" || value === "cancelled"
);
}
function parseTodowriteInput(input: unknown): { todos: unknown[]; merge: boolean } | undefined {
if (!input || typeof input !== "object" || !("todos" in input)) return undefined;
if (!Array.isArray(input.todos)) return undefined;
const merge = "merge" in input && input.merge === true;
return { todos: input.todos, merge };
}
function parseTodoItem(entry: unknown, index: number): TodoItem | undefined {
if (!entry || typeof entry !== "object") return undefined;
if (!("content" in entry) || typeof entry.content !== "string") return undefined;
const id = "id" in entry && typeof entry.id === "string" ? entry.id : String(index);
const status =
"status" in entry && typeof entry.status === "string" && isValidTodoStatus(entry.status)
? entry.status
: "pending";
return { id, content: entry.content, status };
}
function renderTodoMarkdown(todos: TodoItem[]): string {
return todos
.map((todo) => {
switch (todo.status) {
case "completed":
return `- [x] ${todo.content}`;
case "cancelled":
return `- ~~${todo.content}~~`;
case "in_progress":
return `- [ ] <img src="https://uploads.pullfrog.com/Progress%20Indicator.gif" width="11" style="visibility: visible; max-width: 100%;" /> ${todo.content}`;
case "pending":
return `- [ ] ${todo.content}`;
default:
todo.status satisfies never;
return `- [ ] ${todo.content}`;
}
})
.join("\n");
}
export type TodoTracker = {
update: (input: unknown) => void;
flush: () => Promise<void>;
cancel: () => void;
/** resolves when any in-flight onUpdate call completes */
settled: () => Promise<void>;
/** mark in-progress items as completed (for final snapshot before review/progress post) */
completeInProgress: () => void;
renderCollapsible: (options?: { completeInProgress?: boolean }) => string;
readonly enabled: boolean;
/** true after the tracker has successfully called onUpdate at least once */
readonly hasPublished: boolean;
};
const DEBOUNCE_MS = 2000;
export function createTodoTracker(onUpdate: (body: string) => Promise<void>): TodoTracker {
const state = new Map<string, TodoItem>();
let enabled = true;
let hasPublished = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let inflightPromise: Promise<void> = Promise.resolve();
function scheduleUpdate() {
if (!enabled) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
if (!enabled || state.size === 0) return;
const markdown = renderTodoMarkdown(Array.from(state.values()));
inflightPromise = inflightPromise
.then(async () => {
if (!enabled) return;
await onUpdate(markdown);
hasPublished = true;
})
.catch((err) => {
log.debug(`todo progress update failed: ${err}`);
});
}, DEBOUNCE_MS);
}
return {
update(input: unknown) {
if (!enabled) return;
const parsed = parseTodowriteInput(input);
if (!parsed) return;
if (!parsed.merge) state.clear();
for (const [index, entry] of parsed.todos.entries()) {
const item = parseTodoItem(entry, index);
if (item) state.set(item.id, item);
}
log.debug(`» todowrite: ${state.size} items tracked`);
scheduleUpdate();
},
async flush() {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
if (!enabled || state.size === 0) return;
const markdown = renderTodoMarkdown(Array.from(state.values()));
inflightPromise = inflightPromise
.then(async () => {
if (!enabled) return;
await onUpdate(markdown);
hasPublished = true;
})
.catch((err) => {
log.debug(`todo progress flush failed: ${err}`);
});
await inflightPromise;
},
cancel() {
enabled = false;
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
},
async settled() {
await inflightPromise;
},
completeInProgress() {
for (const item of state.values()) {
if (item.status === "in_progress") item.status = "completed";
}
},
renderCollapsible(options?: { completeInProgress?: boolean }): string {
if (state.size === 0) return "";
const shouldCompleteInProgress = options?.completeInProgress === true;
const todos = Array.from(state.values()).map((item) =>
shouldCompleteInProgress && item.status === "in_progress"
? { ...item, status: "completed" as const }
: item
);
const completed = todos.filter((t) => t.status === "completed").length;
const markdown = renderTodoMarkdown(todos);
return `<details>\n<summary>Task list (${completed}/${todos.length} completed)</summary>\n\n${markdown}\n\n</details>`;
},
get enabled() {
return enabled;
},
get hasPublished() {
return hasPublished;
},
};
}