diff --git a/CLAUDE.md b/CLAUDE.md index ac44e0d..72a93b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use. - Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn). - **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems. - **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core). -- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; non-empty stack = unfinished/unreconciled work (turn-start developer reminder + end-of-turn review if still open or untouched). +- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]` turn-start + end-of-turn review); all-terminal non-empty = hygiene (`[TODO HYGIENE]`). - **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`. ### Prompting (`prompting.py`) diff --git a/src/plyngent/agent/todo_stack.py b/src/plyngent/agent/todo_stack.py index e35bcad..e92f2df 100644 --- a/src/plyngent/agent/todo_stack.py +++ b/src/plyngent/agent/todo_stack.py @@ -8,6 +8,7 @@ from msgspec import Struct, field type TodoStatus = Literal["pending", "in_progress", "done", "cancelled"] _OPEN: frozenset[str] = frozenset({"pending", "in_progress"}) +_REVIEW_OPEN_TITLE_LIMIT = 8 class TodoItem(Struct, omit_defaults=True): @@ -184,24 +185,38 @@ class TodoStack: return "\n".join(lines) def turn_reminder_prompt(self) -> str: - """Short mid-context nudge when a turn starts with a non-empty stack.""" + """Mid-context nudge when a turn starts with a non-empty stack. + + A non-empty stack usually means unfinished work (open items) or unfinished + stack hygiene (terminal items still grouped). Prefer finishing real work + over pure bookkeeping when both exist. + """ n_open = len(self.open_items()) n_groups = self.depth if n_open: headline = ( - f"[TODO REMINDER] Stack not empty: {n_open} open item(s) across " - f"{n_groups} group(s). Open items usually mean unfinished work from " - "earlier in the session — continue them, update status, or clear only " - "if intentionally abandoned." + f"[TODO OPEN WORK] Stack not empty: {n_open} open item(s) across " + f"{n_groups} group(s). This is unfinished work from earlier in the " + "session — not optional decoration." + ) + action = ( + "Treat open items as active commitments: continue them, mark " + "done/cancelled when finished, or push a child breakdown for the " + "current TOP item. Only todo_clear if the user abandoned the plan. " + "Do not ignore the stack while answering unrelated chatter." ) else: headline = ( - f"[TODO REMINDER] Stack not empty: {n_groups} group(s) with no open " - "items (all done/cancelled). Pop finished TOP groups or todo_clear if " - "the plan is complete — do not leave stale groups behind." + f"[TODO HYGIENE] Stack not empty: {n_groups} group(s) with no open " + "items (all done/cancelled). Work may be finished; the stack is not." + ) + action = ( + "Pop finished TOP groups (or todo_clear when the whole plan is done). " + "Leaving only-terminal groups wastes context; clean up when you can." ) lines = [ headline, + action, "Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear", "Rules: TOP = current level; push = one sibling group; pop = whole TOP group.", "Stack:", @@ -215,25 +230,27 @@ class TodoStack: n_open = len(open_items) n_groups = self.depth if n_open: + open_titles = "; ".join(f"{item.id}:{item.title}" for item in open_items[:_REVIEW_OPEN_TITLE_LIMIT]) + if n_open > _REVIEW_OPEN_TITLE_LIMIT: + open_titles += f"; …(+{n_open - _REVIEW_OPEN_TITLE_LIMIT} more)" headline = ( - f"[TODO OPEN] Stack not empty: {n_open} open item(s) across " - f"{n_groups} group(s). You stopped while work may still be incomplete." + f"[TODO OPEN WORK] You stopped with {n_open} open item(s) still on " + f"the stack ({n_groups} group(s)). That usually means undone work." ) action = ( - "Do not end the turn with open tasks unaddressed: mark done/cancelled, " - "pop finished TOP groups, push a child breakdown, or clear only if the " - "user no longer wants the plan. Open stack items are a strong signal of " - "undone work." + "Do not end the turn while open tasks remain unaddressed. Next: " + "resume an open item, mark done/cancelled, pop a finished TOP group, " + "or push a child breakdown. Clear only if the user no longer wants " + f"the plan. Open: {open_titles}" ) else: headline = ( - f"[TODO OPEN] Stack not empty: {n_groups} group(s) remain but every " - "item is done/cancelled. Bookkeeping is unfinished." + f"[TODO HYGIENE] Stack still has {n_groups} group(s) but every item " + "is done/cancelled. Real work looks finished; stack cleanup does not." ) action = ( "Pop finished TOP groups (or todo_clear when the whole plan is done). " - "A non-empty stack after all items are terminal still means unfinished " - "task hygiene." + "A non-empty all-terminal stack is unfinished task hygiene, not new work." ) lines = [ headline, diff --git a/src/plyngent/tools/todo.py b/src/plyngent/tools/todo.py index d54c00e..b239970 100644 --- a/src/plyngent/tools/todo.py +++ b/src/plyngent/tools/todo.py @@ -42,9 +42,9 @@ def _notify() -> None: def todo_list() -> str: """Show the LIFO stack of **task groups** (TOP group = current breakdown level). - A non-empty stack usually means unfinished or unreconciled work — keep - updating statuses, pop finished TOP groups, or clear only when abandoning. - Push creates one group of siblings; pop removes the whole top group. + Non-empty stack with open (pending/in_progress) items = unfinished work. + All-terminal but non-empty = hygiene only (pop/clear). Push creates one + group of siblings; pop removes the whole top group. Pattern: push [T1,T2] → push [T1.1,T1.2] → finish children → pop → push [T2.1]… """ stack = _require_stack() @@ -101,8 +101,9 @@ def todo_update( ) -> str: """Update a task by id inside any group. - Open (pending/in_progress) items signal unfinished work — mark done/cancelled - when finished. Pop the TOP group when that breakdown level is complete. + Open items are unfinished work: mark done/cancelled when the work is truly + finished (not just deferred). Pop the TOP group when that breakdown level + is complete so the stack does not linger as false open work. """ stack = _require_stack() status_arg: TodoStatus | None = None diff --git a/tests/test_agent/test_todo_stack.py b/tests/test_agent/test_todo_stack.py index b5326a3..5eb3fd7 100644 --- a/tests/test_agent/test_todo_stack.py +++ b/tests/test_agent/test_todo_stack.py @@ -121,20 +121,23 @@ def test_todo_prompts_signal_undone_work() -> None: stack = TodoStack() item = stack.push("open work") reminder = stack.turn_reminder_prompt() - assert "[TODO REMINDER]" in reminder + assert "[TODO OPEN WORK]" in reminder assert "Stack not empty" in reminder - assert "unfinished" in reminder.lower() + assert "unfinished work" in reminder.lower() assert "open work" in reminder review = stack.review_prompt() - assert "[TODO OPEN]" in review - assert "Stack not empty" in review - assert "undone" in review.lower() or "incomplete" in review.lower() + assert "[TODO OPEN WORK]" in review + assert "undone work" in review.lower() or "open item" in review.lower() assert "open work" in review + assert "t1:open work" in review or "open work" in review stack.update(item.id, status="done") terminal_review = stack.review_prompt() - assert "done/cancelled" in terminal_review or "Bookkeeping" in terminal_review + assert "[TODO HYGIENE]" in terminal_review + assert "done/cancelled" in terminal_review + terminal_reminder = stack.turn_reminder_prompt() + assert "[TODO HYGIENE]" in terminal_reminder def test_legacy_flat_and_frames_migrate() -> None: @@ -259,9 +262,8 @@ async def test_loop_injects_todo_review_when_untouched() -> None: async for _event in agent.run("do stuff"): pass assert client.calls >= 2 - assert any(isinstance(m, DeveloperChatMessage) and "[TODO REMINDER]" in m.content for m in agent.messages) - assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages) - assert not any(isinstance(m, UserChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages) + assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages) + assert not any(isinstance(m, UserChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages) finally: set_todo_stack(None) @@ -288,6 +290,6 @@ async def test_loop_injects_todo_review_when_open_after_touch() -> None: async for _event in agent.run("do stuff"): pass assert client.calls >= 2 - assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN]" in m.content for m in agent.messages) + assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages) finally: set_todo_stack(None)