18c8d34da6
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
168 lines
5.3 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|