From a33982c9b10d57ea0fe5c6c07b8053c1d881b24a Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Tue, 6 Oct 2026 20:55:17 +0200 Subject: [PATCH] feat(todo): add blocked and dropped task statuses blocked keeps a task open but not actionable: it requires a note naming what it waits for, never makes the list stale, and the collapsed card skips it. dropped is terminal: it never counts as open, leaves the done-of-total count, and renders dim without strikethrough. ODD guidance covers projecting both from the feature document. Closes #1814 Closes #1820 --- assets/orchestrator-memory.md | 2 +- docs/gentle-shell.md | 7 ++- extensions/gentle-todo.ts | 11 +++-- lib/shell-todo.ts | 68 +++++++++++++++++++------- tests/gentle-todo.test.ts | 36 ++++++++++++++ tests/odd-integration.test.ts | 8 ++++ tests/shell-todo.test.ts | 90 +++++++++++++++++++++++++++++++++++ 7 files changed, 199 insertions(+), 23 deletions(-) diff --git a/assets/orchestrator-memory.md b/assets/orchestrator-memory.md index 7736bed30..6de532401 100644 --- a/assets/orchestrator-memory.md +++ b/assets/orchestrator-memory.md @@ -21,7 +21,7 @@ On resume, use `mem_context`, then project/feature-scoped `mem_search`, and `mem Before implementation or resume, the parent reads both the actual file and full observation, reconciles them, and passes the locator, task IDs, and linked `S#`; workers read the document until `## Log` before edits. Small work without a document still receives its authorized scope and checks. -The existing `todo` tool is the required session/UI projection for large ODD, not a third authority. After reconciling and writing the durable file and Engram copy, create or rebuild the visible `todo` list from the same feature tasks before the first source write; after every task transition and material plan change, update both durable copies and the visible projection in the same turn; its replay or completed-list clearing must not delete or replace the durable file or Engram copy. If the projection is unavailable, record that limitation without pretending it is synchronized. Small/read-only work does not acquire an ODD artifact or todo list merely because the UI can display tasks. +The existing `todo` tool is the required session/UI projection for large ODD, not a third authority. After reconciling and writing the durable file and Engram copy, create or rebuild the visible `todo` list from the same feature tasks before the first source write; after every task transition and material plan change, update both durable copies and the visible projection in the same turn; its replay or completed-list clearing must not delete or replace the durable file or Engram copy. If the projection is unavailable, record that limitation without pretending it is synchronized. When a change of direction makes tasks obsolete, mark them `dropped` in `## Tasks`, append the reason to `## Log`, and project them as `dropped` in the same turn; never mark them `done` or leave them `pending`. A dropped task never satisfies the tasks that depended on it: rework, reorder, or drop those too. When a task waits on something outside the list (a person, another team, an authorization), mark it `blocked` with a note naming what it waits for and the observable condition that unblocks it; return it to `pending` or `in_progress` once that holds, or `dropped` if abandoned. A prerequisite inside the list is ordering, not blocking: add it as a task and keep the dependent one `pending`. Small/read-only work does not acquire an ODD artifact or todo list merely because the UI can display tasks. Memory lifecycle rule (when Engram exposes lifecycle metadata/tooling): diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index dd07693a6..52d9331d6 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -257,7 +257,12 @@ Three things keep the list current, which a static tool description cannot: - `write` replaces the whole list in one call, so the model rewrites the plan instead of patching it; `add`, `update`, `clear`, and `list` remain for single moves. - Every turn's system prompt carries the open tasks and the rules: in_progress before starting, done right after finishing, update before ending the turn. -- A list that goes two turns untouched while tasks stay open turns amber with `stale · N turns`, and the prompt says so, so the model brings it up to date. +- A list that goes two turns untouched while pending or in-progress tasks remain turns amber with `stale · N turns`, and the prompt says so, so the model brings it up to date. + +Tasks are `pending` (`○`), `in_progress` (`◐`), `blocked` (`⊘`), `done` (`✓`), or `dropped` (`✕`): + +- `blocked` is open work that waits on something outside the list, such as an admin, another team, or an authorization. It requires a note naming what it waits for, shown next to the title (`⊘ Deploy the cleanup job · waiting for an admin`). Blocked tasks keep the list open, but they never make it stale, and the collapsed card skips them for the next task. +- `dropped` marks a task a change of plan made obsolete. It renders dim without strikethrough, leaves the `done of total` count, and does not count as open, so a list whose remaining tasks are all `done` or `dropped` is finished. `cancelled`, `canceled`, and `abandoned` are accepted as aliases. A finished list stays on screen for the turn it finished in and clears at the next. `ctrl+shift+t` collapses the card to the task in progress (`GENTLE_PI_TODO_KEY` rebinds it, `off` disables it); `GENTLE_PI_TODO=0` disables the tool and the card. diff --git a/extensions/gentle-todo.ts b/extensions/gentle-todo.ts index 4d9596d4b..cf4523573 100644 --- a/extensions/gentle-todo.ts +++ b/extensions/gentle-todo.ts @@ -29,6 +29,7 @@ import { // the human and the model. const WIDGET_KEY = "gentle-todo"; +const STATUS_ENUM = ["pending", "in_progress", "blocked", "done", "dropped"]; const COLLAPSE_KEY_DEFAULT = "ctrl+shift+t"; const TOOL_PARAMETERS = { type: "object", @@ -46,15 +47,15 @@ const TOOL_PARAMETERS = { properties: { id: { type: "integer", description: "Existing task id to keep." }, title: { type: "string", description: "Short imperative title, e.g. 'Write the parser'." }, - status: { type: "string", enum: ["pending", "in_progress", "done"], description: "Defaults to pending." }, - note: { type: "string", description: "What is happening right now, shown while in_progress, e.g. 'writing tests'." }, + status: { type: "string", enum: STATUS_ENUM, description: "Defaults to pending. blocked: open but waiting on something outside the list; dropped: will not be done, on purpose." }, + note: { type: "string", description: "What is happening right now, shown while in_progress, e.g. 'writing tests'; required for blocked, naming what it waits for, e.g. 'waiting for an admin'." }, }, }, }, id: { type: "integer", description: "Task id for update." }, title: { type: "string", description: "Title for add, or a new title for update." }, - status: { type: "string", enum: ["pending", "in_progress", "done"], description: "Status for add or update." }, - note: { type: "string", description: "Note for add or update." }, + status: { type: "string", enum: STATUS_ENUM, description: "Status for add or update." }, + note: { type: "string", description: "Note for add or update; required for blocked." }, }, } as const; @@ -183,6 +184,8 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr "Mark a task in_progress before starting it and done right after finishing it; keep exactly one task in_progress.", "Prefer write with the complete list whenever the plan changes; keep ids of tasks that already exist.", "Never mark a task done while tests fail or the work is partial; add a task for the blocker instead.", + "Mark a task blocked with a note naming what it waits for only when that is outside the list (a person, another team, an authorization); a prerequisite in the list is ordering, so keep the task pending. Return it to pending once the condition holds.", + "When the plan changes, mark tasks it made obsolete dropped, never done; dropped tasks no longer count as open.", ], parameters: TOOL_PARAMETERS, executionMode: "sequential", diff --git a/lib/shell-todo.ts b/lib/shell-todo.ts index ac36173b6..76b69738e 100644 --- a/lib/shell-todo.ts +++ b/lib/shell-todo.ts @@ -11,7 +11,11 @@ import { paintHoverable } from "./shell-hover.ts"; export const TODO_STATUS = { PENDING: "pending", IN_PROGRESS: "in_progress", + /** Open, but waiting on something outside the list; always carries a note naming it. */ + BLOCKED: "blocked", DONE: "done", + /** Will not be done, on purpose: terminal, and never counts as open. */ + DROPPED: "dropped", } as const; export type TodoStatus = (typeof TODO_STATUS)[keyof typeof TODO_STATUS]; @@ -86,10 +90,20 @@ export interface TodoRenderOptions { export const TODO_DETAILS_KEY = "gentleTodo"; export const TODO_TOOL_NAME = "todo"; export const TODO_GLYPH = "❀"; -const STATUS_ALIASES: Record = { completed: TODO_STATUS.DONE, complete: TODO_STATUS.DONE, doing: TODO_STATUS.IN_PROGRESS, todo: TODO_STATUS.PENDING }; -const STATUS_GLYPH: Record = { [TODO_STATUS.PENDING]: "○", [TODO_STATUS.IN_PROGRESS]: "◐", [TODO_STATUS.DONE]: "✓" }; -const STATUS_ROLE: Record = { [TODO_STATUS.PENDING]: "text", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.DONE]: "dim" }; -const GLYPH_ROLE: Record = { [TODO_STATUS.PENDING]: "muted", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.DONE]: "success" }; +const STATUS_ALIASES: Record = { + completed: TODO_STATUS.DONE, + complete: TODO_STATUS.DONE, + doing: TODO_STATUS.IN_PROGRESS, + todo: TODO_STATUS.PENDING, + cancelled: TODO_STATUS.DROPPED, + canceled: TODO_STATUS.DROPPED, + abandoned: TODO_STATUS.DROPPED, +}; +const STATUS_LIST = "pending, in_progress, blocked, done, or dropped"; +const BLOCKED_NEEDS_NOTE = "blocked needs a note naming what it waits for"; +const STATUS_GLYPH: Record = { [TODO_STATUS.PENDING]: "○", [TODO_STATUS.IN_PROGRESS]: "◐", [TODO_STATUS.BLOCKED]: "⊘", [TODO_STATUS.DONE]: "✓", [TODO_STATUS.DROPPED]: "✕" }; +const STATUS_ROLE: Record = { [TODO_STATUS.PENDING]: "text", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.BLOCKED]: "text", [TODO_STATUS.DONE]: "dim", [TODO_STATUS.DROPPED]: "dim" }; +const GLYPH_ROLE: Record = { [TODO_STATUS.PENDING]: "muted", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.BLOCKED]: "warning", [TODO_STATUS.DONE]: "success", [TODO_STATUS.DROPPED]: "muted" }; const NOTE_ROLE = "muted"; const STALE_AFTER_TURNS = 2; /** Above this many rows the finished tasks fold into one line and the rest is capped. */ @@ -111,20 +125,30 @@ function parseStatus(value: unknown): TodoStatus | undefined { return STATUS_ALIASES[normalized]; } +function countStatus(state: TodoState, status: TodoStatus): number { + return state.tasks.filter((task) => task.status === status).length; +} + +/** Dropped tasks are neither done nor open. */ export function todoSummary(state: TodoState): TodoSummary { - const done = state.tasks.filter((task) => task.status === TODO_STATUS.DONE).length; - return { done, total: state.tasks.length, open: state.tasks.length - done }; + const done = countStatus(state, TODO_STATUS.DONE); + return { done, total: state.tasks.length, open: state.tasks.length - done - countStatus(state, TODO_STATUS.DROPPED) }; } +// Only work the model can pick up goes stale: a list whose open tasks all +// wait on something outside it has nothing to bring up to date. export function staleTurns(state: TodoState, currentTurn: number): number { - if (state.updatedTurn === null || todoSummary(state).open === 0) return 0; + const actionable = state.tasks.some((task) => task.status === TODO_STATUS.PENDING || task.status === TODO_STATUS.IN_PROGRESS); + if (state.updatedTurn === null || !actionable) return 0; return Math.max(0, currentTurn - state.updatedTurn); } function summaryText(state: TodoState): string { const { done, total } = todoSummary(state); - const inProgress = state.tasks.filter((task) => task.status === TODO_STATUS.IN_PROGRESS).length; - return `${total} ${total === 1 ? "task" : "tasks"} · ${done} done · ${inProgress} in progress`; + const inProgress = countStatus(state, TODO_STATUS.IN_PROGRESS); + const blocked = countStatus(state, TODO_STATUS.BLOCKED); + const dropped = countStatus(state, TODO_STATUS.DROPPED); + return `${total} ${total === 1 ? "task" : "tasks"} · ${done} done · ${inProgress} in progress${blocked ? ` · ${blocked} blocked` : ""}${dropped ? ` · ${dropped} dropped` : ""}`; } function taskLine(task: TodoTask): string { @@ -135,8 +159,9 @@ function buildTask(input: TodoTaskInput, id: number): TodoTask | string { const title = cleanText(input.title); if (title.length === 0) return "title is required"; const status = parseStatus(input.status); - if (status === undefined) return `unknown status "${input.status}" (use pending, in_progress, or done)`; + if (status === undefined) return `unknown status "${input.status}" (use ${STATUS_LIST})`; const note = cleanText(input.note); + if (status === TODO_STATUS.BLOCKED && note.length === 0) return BLOCKED_NEEDS_NOTE; return { id, title, status, ...(note ? { note } : {}) }; } @@ -240,6 +265,7 @@ export function todoPromptBlock(state: TodoState, stale: number): string | undef return [ "## Todo list", "Keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.", + "A blocked task waits on something outside the list: do not work on it until its condition holds. When the plan changes, mark the tasks it made obsolete dropped instead of leaving them pending or marking them done.", ...lines, ].join("\n") + staleLine; } @@ -253,18 +279,22 @@ function taskRow(task: TodoTask, theme: TodoTheme, inner: number): string { .join("\n"); } const title = task.title; - const note = task.status === TODO_STATUS.IN_PROGRESS && task.note ? ` ${theme.fg(NOTE_ROLE, "·")} ${theme.fg(NOTE_ROLE, task.note)}` : ""; + const showsNote = task.status === TODO_STATUS.IN_PROGRESS || task.status === TODO_STATUS.BLOCKED; + const note = showsNote && task.note ? ` ${theme.fg(NOTE_ROLE, "·")} ${theme.fg(NOTE_ROLE, task.note)}` : ""; return `${theme.fg(GLYPH_ROLE[task.status], STATUS_GLYPH[task.status])} ${theme.fg(STATUS_ROLE[task.status], title)}${note}`; } -// Long lists keep the card short: done tasks fold into "✓ N done" and the -// open ones fill the remaining rows, with a trailing count for the rest. +// Long lists keep the card short: done and dropped tasks fold into "✓ N done" +// and "✕ N dropped", and the open ones fill the remaining rows, with a +// trailing count for the rest. function bodyRows(state: TodoState, theme: TodoTheme, inner: number): string[] { if (state.tasks.length <= ROW_CAP) return state.tasks.map((task) => taskRow(task, theme, inner)); - const { done } = todoSummary(state); - const open = state.tasks.filter((task) => task.status !== TODO_STATUS.DONE); + const open = state.tasks.filter((task) => task.status !== TODO_STATUS.DONE && task.status !== TODO_STATUS.DROPPED); const rows: string[] = []; - if (done > 0) rows.push(`${theme.fg(GLYPH_ROLE[TODO_STATUS.DONE], STATUS_GLYPH[TODO_STATUS.DONE])} ${theme.fg(NOTE_ROLE, `${done} done`)}`); + for (const [status, label] of [[TODO_STATUS.DONE, "done"], [TODO_STATUS.DROPPED, "dropped"]] as const) { + const count = countStatus(state, status); + if (count > 0) rows.push(`${theme.fg(GLYPH_ROLE[status], STATUS_GLYPH[status])} ${theme.fg(NOTE_ROLE, `${count} ${label}`)}`); + } const room = ROW_CAP - rows.length - (open.length > ROW_CAP - rows.length ? 1 : 0); for (const task of open.slice(0, room)) rows.push(taskRow(task, theme, inner)); if (open.length > room) rows.push(theme.fg(NOTE_ROLE, `… ${open.length - room} more`)); @@ -276,6 +306,8 @@ function collapsedRow(state: TodoState, theme: TodoTheme, inner: number): string if (active) return taskRow(active, theme, inner); const pending = state.tasks.find((task) => task.status === TODO_STATUS.PENDING); if (pending) return taskRow(pending, theme, inner); + const blocked = state.tasks.find((task) => task.status === TODO_STATUS.BLOCKED); + if (blocked) return taskRow(blocked, theme, inner); const { open } = todoSummary(state); return `${theme.fg(GLYPH_ROLE[TODO_STATUS.PENDING], STATUS_GLYPH[TODO_STATUS.PENDING])} ${theme.fg(NOTE_ROLE, `${open} open`)}`; } @@ -288,6 +320,8 @@ export function todoCardTone(staleTurns: number): CardTone { export function renderTodoCard(state: TodoState, theme: TodoTheme, width: number, options: TodoRenderOptions): string[] { if (state.tasks.length === 0) return []; const { done, total } = todoSummary(state); + // Dropped work is not part of the plan anymore, so it leaves the denominator. + const planned = total - countStatus(state, TODO_STATUS.DROPPED); const stale = options.staleTurns >= STALE_AFTER_TURNS; const action = options.collapsed ? "expand" : "collapse"; const actionLabel = action[0]!.toUpperCase() + action.slice(1); @@ -305,7 +339,7 @@ export function renderTodoCard(state: TodoState, theme: TodoTheme, width: number // same treatment every other clickable surface uses -- instead of its // ordinary accent role. return renderCard( - { title: `Todos ${paintHoverable(theme, control, options.hovered, "accent")}`, subtitle: `${done} of ${total}`, body, tone, glyph: TODO_GLYPH }, + { title: `Todos ${paintHoverable(theme, control, options.hovered, "accent")}`, subtitle: `${done} of ${planned}`, body, tone, glyph: TODO_GLYPH }, theme, width, { expanded: true, hint, panel: true }, diff --git a/tests/gentle-todo.test.ts b/tests/gentle-todo.test.ts index 9b305fb8c..dc5b40cf7 100644 --- a/tests/gentle-todo.test.ts +++ b/tests/gentle-todo.test.ts @@ -309,3 +309,39 @@ test("session_start drops a list that was already finished, so a reload never sh const prompt = (await fire("before_agent_start", ctx, { systemPrompt: "base" })) as { systemPrompt: string } | undefined; assert.equal(prompt, undefined, "and nothing is injected into the prompt"); }); + +test("the todo tool offers blocked and dropped in its schema and guidelines", () => { + const { pi, tools } = fakePi(); + gentleTodo(pi, {}); + const tool = tools.get("todo") as unknown as { parameters: { properties: Record } }> }; promptGuidelines: string[] }; + const statuses = ["pending", "in_progress", "blocked", "done", "dropped"]; + assert.deepEqual(tool.parameters.properties.status.enum, statuses); + assert.deepEqual(tool.parameters.properties.tasks.items!.properties.status.enum, statuses); + assert.match(tool.parameters.properties.tasks.items!.properties.note.description!, /required for blocked/); + assert.ok(tool.promptGuidelines.some((line) => /blocked with a note naming what it waits for/.test(line))); + assert.ok(tool.promptGuidelines.some((line) => /dropped/.test(line) && /obsolete/.test(line))); +}); + +test("a list left with only dropped tasks open finishes and clears; a blocked-only list stays in the prompt without going stale", async () => { + const { pi, tools, fire } = fakePi(); + gentleTodo(pi, {}); + const { ctx, widget } = fakeContext(); + await fire("session_start", ctx); + await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "A", status: "done" }, { title: "Old plan", status: "dropped" }] }, undefined, undefined, ctx); + await fire("tool_execution_end", ctx, { toolName: "todo" }); + await fire("agent_end", ctx); + assert.match(widget()![0], /Todos ▾ Collapse · 1 of 1/); + await fire("before_agent_start", ctx, promptEvent()); + assert.equal(widget(), undefined, "a done-and-dropped list clears at the next turn"); + + await tools.get("todo")!.execute("c2", { action: "write", tasks: [{ title: "A", status: "done" }, { title: "Deploy", status: "blocked", note: "waiting for an admin" }] }, undefined, undefined, ctx); + await fire("tool_execution_end", ctx, { toolName: "todo" }); + for (let turn = 0; turn < 4; turn++) { + await fire("agent_end", ctx); + const event = promptEvent(); + await fire("before_agent_start", ctx, event); + assert.match(event.systemPromptOptions.appendSystemPrompt, /2\. \[blocked\] Deploy — waiting for an admin/); + assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /stale/); + } + assert.match(widget()![2], /⊘ Deploy · waiting for an admin/); +}); diff --git a/tests/odd-integration.test.ts b/tests/odd-integration.test.ts index 875da9381..866833544 100644 --- a/tests/odd-integration.test.ts +++ b/tests/odd-integration.test.ts @@ -66,3 +66,11 @@ test("retired SDD routes and assets are absent while ODD entry and generic worke ]) assert.equal(existsSync(new URL(`../${path}`, import.meta.url)), false, path); assert.doesNotMatch(core + delegation + read("extensions/gentle-ai.ts"), /(?:\/sdd-(?:init|explore|status|apply|verify|archive)|sdd-full\.chain|sdd-orchestrator-workflow\.md)/); }); + +test("ODD projects blocked and dropped tasks to the todo list on a change of plan (#1814, #1820)", () => { + const memory = read("assets/orchestrator-memory.md"); + assert.match(memory, /When a change of direction makes tasks obsolete, mark them `dropped` in `## Tasks`, append the reason to `## Log`, and project them as `dropped` in the same turn/); + assert.match(memory, /A dropped task never satisfies the tasks that depended on it/); + assert.match(memory, /mark it `blocked` with a note naming what it waits for and the observable condition that unblocks it/); + assert.match(memory, /A prerequisite inside the list is ordering, not blocking/); +}); diff --git a/tests/shell-todo.test.ts b/tests/shell-todo.test.ts index 6c80aacff..d5c6bf90f 100644 --- a/tests/shell-todo.test.ts +++ b/tests/shell-todo.test.ts @@ -379,3 +379,93 @@ test("a done todo wraps to the float body so the strikethrough never re-wraps", assertFloatRows(lines, 40); for (const row of lines.slice(3, -1)) assert.match(stripAnsi(row), /^ ▎ [✓ ] ~[^~]+~ +$/u, "each physical row closes its own strikethrough"); }); + +// #1814 dropped: terminal, never counts as open, reads apart from done. +// #1820 blocked: open but not actionable, always carries its reason. + +test("applyTodo accepts dropped and blocked, maps cancel vocabulary to dropped, and names every status on an unknown one", () => { + const { state, error } = applyTodo( + emptyTodo(), + { action: "write", tasks: [{ title: "Old plan", status: "dropped" }, { title: "Deploy", status: "blocked", note: "waiting for an admin" }, { title: "A", status: "cancelled" }, { title: "B", status: "canceled" }, { title: "C", status: "abandoned" }] }, + 1, + ); + assert.equal(error, undefined); + assert.deepEqual(state.tasks.map((task) => task.status), [TODO_STATUS.DROPPED, TODO_STATUS.BLOCKED, TODO_STATUS.DROPPED, TODO_STATUS.DROPPED, TODO_STATUS.DROPPED]); + assert.equal(state.tasks[1].note, "waiting for an admin"); + const bad = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "X", status: "later" }] }, 1); + assert.equal(bad.error, 'unknown status "later" (use pending, in_progress, blocked, done, or dropped)'); +}); + +test("applyTodo rejects blocked without a note and leaves the list unchanged", () => { + const before = seeded(); + for (const params of [ + { action: "add", title: "Deploy", status: "blocked" }, + { action: "update", id: 3, status: "blocked" }, + { action: "write", tasks: [{ title: "Deploy", status: "blocked", note: " " }] }, + ]) { + const result = applyTodo(before, params, 9); + assert.equal(result.error, "blocked needs a note naming what it waits for"); + assert.equal(result.text, "Error: blocked needs a note naming what it waits for"); + assert.strictEqual(result.state, before, `${params.action} must not change the list`); + } + const blocked = applyTodo(before, { action: "update", id: 3, status: "blocked", note: "waiting for the API contract" }, 9); + assert.equal(blocked.error, undefined); + assert.equal(blocked.text, "#3 Show git bash tails → blocked"); + const unblocked = applyTodo(blocked.state, { action: "update", id: 3, status: "pending" }, 10); + assert.equal(unblocked.state.tasks[2].status, TODO_STATUS.PENDING); +}); + +test("dropped tasks never count as open: a list of done and dropped is finished, silent, and never stale", () => { + const state = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "A", status: "done" }, { title: "B", status: "dropped" }, { title: "C", status: "dropped" }] }, 1).state; + assert.deepEqual(todoSummary(state), { done: 1, total: 3, open: 0 }); + assert.equal(todoPromptBlock(state, 5), undefined); + assert.equal(staleTurns(state, 9), 0); +}); + +test("blocked tasks stay open but alone never make the list stale; an actionable task still does", () => { + const blockedOnly = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "A", status: "done" }, { title: "Deploy", status: "blocked", note: "waiting for an admin" }] }, 1).state; + assert.deepEqual(todoSummary(blockedOnly), { done: 1, total: 2, open: 1 }); + assert.equal(staleTurns(blockedOnly, 9), 0); + const block = todoPromptBlock(blockedOnly, 0) ?? ""; + assert.match(block, /2\. \[blocked\] Deploy — waiting for an admin/); + assert.match(block, /A blocked task waits on something outside the list: do not work on it until its condition holds/); + assert.match(block, /mark the tasks it made obsolete dropped instead of leaving them pending or marking them done/); + const mixed = applyTodo(blockedOnly, { action: "add", title: "Write docs" }, 1).state; + assert.equal(staleTurns(mixed, 3), 2); +}); + +test("renderTodoCard draws blocked with its reason and dropped dim without strikethrough, excluding dropped from the count", () => { + const state = applyTodo( + emptyTodo(), + { action: "write", tasks: [{ title: "Ship", status: "done" }, { title: "Old plan", status: "dropped" }, { title: "Deploy", status: "blocked", note: "waiting for an admin" }, { title: "Docs" }] }, + 1, + ).state; + const plain = renderTodoCard(state, plainTheme, 60, { collapsed: false, staleTurns: 0 }).map(stripAnsi); + assert.match(plain[0], /Todos ▾ Collapse · 1 of 3/); + assert.match(plain[2], /^│ ✕ Old plan +│$/); + assert.match(plain[3], /^│ ⊘ Deploy · waiting for an admin +│$/); + const roles: string[] = []; + const spy = { ...plainTheme, fg(color: string, text: string) { roles.push(`${color}:${text}`); return text; } }; + renderTodoCard(state, spy, 60, { collapsed: false, staleTurns: 0 }); + assert.ok(roles.includes("muted:✕") && roles.includes("dim:Old plan"), "dropped: muted glyph, dim title"); + assert.ok(roles.includes("warning:⊘"), "blocked: warning glyph"); +}); + +test("the collapsed card skips blocked tasks for the next one, and shows a blocked task only when nothing else is open", () => { + const state = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "Deploy", status: "blocked", note: "waiting for an admin" }, { title: "Docs" }] }, 1).state; + assert.match(renderTodoCard(state, plainTheme, 60, { collapsed: true, staleTurns: 0 }).map(stripAnsi)[1], /^│ ○ Docs +│$/); + const onlyBlocked = applyTodo(state, { action: "update", id: 2, status: "done" }, 2).state; + assert.match(renderTodoCard(onlyBlocked, plainTheme, 60, { collapsed: true, staleTurns: 0 }).map(stripAnsi)[1], /^│ ⊘ Deploy · waiting for an admin +│$/); +}); + +test("renderTodoCard folds dropped tasks next to done ones in a long list", () => { + const tasks = Array.from({ length: 20 }, (_, index) => ({ title: `Task ${index + 1}`, status: index < 5 ? "done" : index < 8 ? "dropped" : "pending" })); + const state = applyTodo(emptyTodo(), { action: "write", tasks }, 1).state; + const plain = renderTodoCard(state, plainTheme, 60, { collapsed: false, staleTurns: 0 }).map(stripAnsi); + assert.equal(plain.length, 14); + assert.match(plain[0], /Todos ▾ Collapse · 5 of 17/); + assert.match(plain[1], /^│ ✓ 5 done +│$/); + assert.match(plain[2], /^│ ✕ 3 dropped +│$/); + assert.match(plain[3], /^│ ○ Task 9 +│$/); + assert.match(plain[12], /^│ … 3 more +│$/); +});