Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/orchestrator-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
7 changes: 6 additions & 1 deletion docs/gentle-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 7 additions & 4 deletions extensions/gentle-todo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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;

Expand Down Expand Up @@ -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",
Expand Down
68 changes: 51 additions & 17 deletions lib/shell-todo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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<string, TodoStatus> = { completed: TODO_STATUS.DONE, complete: TODO_STATUS.DONE, doing: TODO_STATUS.IN_PROGRESS, todo: TODO_STATUS.PENDING };
const STATUS_GLYPH: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "○", [TODO_STATUS.IN_PROGRESS]: "◐", [TODO_STATUS.DONE]: "✓" };
const STATUS_ROLE: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "text", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.DONE]: "dim" };
const GLYPH_ROLE: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "muted", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.DONE]: "success" };
const STATUS_ALIASES: Record<string, TodoStatus> = {
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<TodoStatus, string> = { [TODO_STATUS.PENDING]: "○", [TODO_STATUS.IN_PROGRESS]: "◐", [TODO_STATUS.BLOCKED]: "⊘", [TODO_STATUS.DONE]: "✓", [TODO_STATUS.DROPPED]: "✕" };
const STATUS_ROLE: Record<TodoStatus, string> = { [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<TodoStatus, string> = { [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. */
Expand All @@ -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 {
Expand All @@ -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 } : {}) };
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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`));
Expand All @@ -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`)}`;
}
Expand All @@ -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);
Expand All @@ -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 },
Expand Down
36 changes: 36 additions & 0 deletions tests/gentle-todo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { enum?: string[]; items?: { properties: Record<string, { enum?: string[]; description?: string }> } }> }; 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/);
});
8 changes: 8 additions & 0 deletions tests/odd-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading
Loading