diff --git a/docs/architecture/rfcs/human-attention-wishlist-v0.md b/docs/architecture/rfcs/human-attention-wishlist-v0.md index a8a0a2e3ce..799059cabf 100644 --- a/docs/architecture/rfcs/human-attention-wishlist-v0.md +++ b/docs/architecture/rfcs/human-attention-wishlist-v0.md @@ -58,9 +58,9 @@ The current seams do not compose into that outcome: non-notifying optional-human template; - an open `user_action` can enter the user notification channel even when it is non-blocking; -- `todo suggest` creates a read-only candidate queue that requires later - promotion; the retired `todo capture-followups` command wrote only agent - work and never provided a human-wish route; +- the retired `todo suggest` command only emitted an advisory prompt; the + retired `todo capture-followups` command wrote only agent work. Neither + provided a durable human-wish route; - the compact turn envelope carries required execution and writeback actions, but no signed optional sidecar hint. @@ -369,11 +369,11 @@ ownership, and lifecycle are already those of a non-blocking user action. Rejected. Current interaction behavior can notify every visible user action, and substring or prose classification would make routing authority ambiguous. -### Keep wishes only in `todo suggest` +### Keep wishes only in an advisory suggestion -Rejected. The suggestion surface is intentionally read-only and requires later -promotion, so it cannot preserve a small opportunity discovered as a normal -turn side effect. +Rejected. An advisory proposal requires later promotion, so it cannot preserve +a small opportunity discovered as a normal turn side effect. Retiring the +standalone suggestion command does not close this lifecycle gap. ### Write every opportunity as an agent todo @@ -394,7 +394,6 @@ Only after the first slice produces real usage evidence should LoopX consider: - a user preference or digest policy for wishlist visibility; - accept/decline convenience commands and atomic agent-todo promotion; - value/acceptance metrics based on typed lifecycle events; -- teaching `todo suggest` to return separate agent candidates and human wishes; - external projection sinks that render the existing wishlist lane. These are not required for v0 and must not delay the non-blocking authoring diff --git a/docs/architecture/rfcs/human-attention-wishlist-v0.zh-CN.md b/docs/architecture/rfcs/human-attention-wishlist-v0.zh-CN.md index 664fd1f365..2a942eed83 100644 --- a/docs/architecture/rfcs/human-attention-wishlist-v0.zh-CN.md +++ b/docs/architecture/rfcs/human-attention-wishlist-v0.zh-CN.md @@ -40,8 +40,8 @@ LoopX 已经区分阻塞性的 `user_gate` todo 与非阻塞的 `user_action` to - heartbeat 指南要求记录高价值候选,却没有定义 wishlist 写命令或生命周期; - `todo_write_hint` 提供 gate、user-action 和 agent-todo 模板,却没有“不通知的可选人类请求”模板; - 一个打开的 `user_action` 即使非阻塞,也可能进入用户通知通道; -- `todo suggest` 只产生只读候选队列,还需要后续 promotion;已退役的 - `todo capture-followups` 只写 agent work,从未提供 human wish 路由; +- 已退役的 `todo suggest` 只生成 advisory prompt;已退役的 + `todo capture-followups` 只写 agent work。两者都没有提供持久化 human wish 路由; - compact turn envelope 带有必须执行的动作和写回,却没有签名过的可选 sidecar 提示。 结果是一种可以避免的生产偏差:agent 要么把可选价值升级成 blocker,要么制造提醒噪音,要么遗忘它。 @@ -269,9 +269,9 @@ v0 拒绝。它会扩大每个 task-class switch、CLI validator、state project 拒绝。当前 interaction behavior 可能通知每个可见 user action;substring/prose classification 还会让 routing authority 变得模糊。 -### 只把 wish 放在 `todo suggest` +### 只把 wish 留在 advisory suggestion 中 -拒绝。Suggestion surface 刻意只读且需要后续 promotion,无法保存在普通 turn 中发现的小机会。 +拒绝。Advisory proposal 需要后续 promotion,无法保存在普通 turn 中发现的小机会。退役独立 suggestion 命令不等于补齐这一生命周期。 ### 把每个机会都写成 agent todo @@ -288,7 +288,6 @@ v0 拒绝。它会扩大每个 task-class switch、CLI validator、state project - 用户层的 wishlist visibility/digest preference; - accept/decline convenience command 与原子 agent-todo promotion; - 基于 typed lifecycle event 的 value/acceptance metric; -- 教会 `todo suggest` 分开返回 agent candidate 与 human wish; - 渲染既有 wishlist lane 的 external projection sink。 这些都不是 v0 必需项,不应延迟非阻塞写入协议。 diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index db914d158a..68207791cc 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2808,8 +2808,10 @@ wrappers while retaining `prepareCoordinationProjectionCommit` and the shared reducer used by live domain transactions. It also removes the unrelated public `todo capture-followups` product command. That command retirement does not remove, weaken, or rename the runtime shadow-capture mechanism described here. -`todo suggest` remains a manual read-only discovery entrypoint and is not part -of provider promotion qualification. +The standalone `todo suggest` prompt command is also retired; candidate +analysis uses the current agent and existing Todo read/authoring paths, without +adding a discovery wrapper or provider-promotion prerequisite. No stored Todo +or authority history is removed by either public command retirement. #### Refactoring roadmap overview diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 73a6328487..f5a04e5dc4 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -2227,8 +2227,9 @@ backend、实时双向同步或按命令拆开的权威;晋升后不支持的 `coordination.local_authority.mutate` 与 Todo compatibility-edit 执行包装,同时保留 实际领域事务仍复用的 `prepareCoordinationProjectionCommit` 与共享 reducer;另行删除 无关的公开产品命令 `todo capture-followups`。后一个命令的退役不删除、不削弱、也不 -重命名本 RFC 的 runtime shadow-capture 机制。`todo suggest` 继续作为人工触发的只读 -发现入口,不纳入 provider promotion 资格。 +重命名本 RFC 的 runtime shadow-capture 机制。独立 prompt 命令 `todo suggest` 也退役; +候选分析复用当前 Agent 与既有 Todo 读取/写入路径,不新增 discovery 包装或 provider +promotion 前置条件。两个公开命令的退役都不删除已保存的 Todo 或 authority 历史。 #### 重构主线总览 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 9d3a6848ce..f686f145f6 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -983,8 +983,10 @@ readers but does not finish Todo writers, retention/compaction or promotion. unused public `todo capture-followups` batch command instead of migrating it; ordinary `todo add` remains available but is not claimed to preserve the retired command's atomic batch, deduplication, or replay contract. The - read-only, manually invoked `todo suggest` surface remains, but is not a - provider-default prerequisite. + standalone `todo suggest` prompt command is also retired. Candidate analysis + stays with the current agent and existing Todo read/authoring paths; it does + not need a renamed command or a new wrapper protocol. See the + [discovery and compatibility boundary](../../reference/protocols/long-horizon-agent-state-protocol-v0.md#candidate-discovery-and-command-retirement). - Depends on T1–T3 and the shared RFC's [D1–D3](shared-goal-authority-state-provider-v0.md#durability-execution-cards), including owner approval and the explicit legacy migration window. Search remaining imports and public command routes before deleting old Markdown business writers, diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index d5dfcf50db..ff8209f3b1 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -763,8 +763,9 @@ T3/D1 reader,未完成全部 Todo writer、retention/compaction 或 promotion 和 team-plan 事务仍复用 projection reduction 与 commit preparation,因此这些 公共内核保留。同一审计直接退役无实际调用的公开批量命令 `todo capture-followups`,不再为它继续迁移;普通 `todo add` 仍可用,但不宣称保留 - 已退役命令的批量原子性、去重或 replay 合同。人工触发、只读的 `todo suggest` - 保留,但不是 provider 默认切换的前置条件。 + 已退役命令的批量原子性、去重或 replay 合同。独立 prompt 命令 `todo suggest` + 也直接退役;候选分析由当前 Agent 结合既有 Todo 读取和写入路径完成,不增加改名命令 + 或包装协议。见[发现与兼容边界](../../reference/protocols/long-horizon-agent-state-protocol-v0.md#candidate-discovery-and-command-retirement)。 - 前提是 T1–T3 和 shared RFC 的 [D1–D3](shared-goal-authority-state-provider-v0.zh-CN.md#持久化执行卡),包括 owner 批准及明确的 legacy 迁移窗口。 搜索剩余 import 和公开路由后,删除旧 Markdown 业务 writer、capture-only adapter、 重复 reference aggregate。 diff --git a/docs/reference/protocols/README.md b/docs/reference/protocols/README.md index ea1e9b6805..9e82a42fbf 100644 --- a/docs/reference/protocols/README.md +++ b/docs/reference/protocols/README.md @@ -25,7 +25,6 @@ scanning a chronological list. - [`typed_date_resume_trigger_v0`](typed-date-resume-trigger-v0.md): timezone-aware one-shot Todo resume and stable due receipt v0 - [`typed_date_resume_trigger_v0`](typed-date-resume-trigger-v0.zh-CN.md): timezone-aware one-shot Todo resume and stable due receipt v0 (中文) - [`todo_detail_cold_path_v0`](todo-detail-cold-path-v0.md): Todo detail cold path v0 -- [`todo_suggestion_prompt_v0`](todo-suggestion-prompt-v0.md): Todo suggestion prompt v0 - [`turn_envelope_v0`](turn-envelope-v0.md): Turn envelope v0 - [`loop_turn_loop_disposition_v0`](turn-loop-controller-v0.md): Loop Turn Loop Disposition v0 diff --git a/docs/reference/protocols/long-horizon-agent-state-protocol-v0.md b/docs/reference/protocols/long-horizon-agent-state-protocol-v0.md index 1374a306db..1de7f48ace 100644 --- a/docs/reference/protocols/long-horizon-agent-state-protocol-v0.md +++ b/docs/reference/protocols/long-horizon-agent-state-protocol-v0.md @@ -36,7 +36,6 @@ them directly. | `connection_state` | `loopx connect`, `bootstrap`, `doctor`, `sync-global` | Whether the repo is connected, read-only, bootstrapped, stale, or missing local state. | | `local_state_boundary` | `.gitignore`, `loopx check`, getting-started docs | Keep `.loopx/`, `.codex/goals/`, `.local/`, raw logs, credentials, and private paths out of public commits. | | `todo_item_v0` | `loopx todo`, active-state todo sections, `loopx/status.py` | Formal work unit with role, status, task class, action kind, claim, dependency, resume, and evidence metadata. | -| `suggested_todo` | `loopx todo suggest`, `todo_suggestion_prompt_v0` | Candidate decision queue; not formal backlog until promoted by user/controller. | | `interaction_contract_v0` | `loopx quota should-run`, `docs/quota-allocation.md` | Splits user, agent, and CLI obligations before an automated turn spends compute. | | `agent_lane_next_action_v0` | `loopx quota should-run --agent-id ...`, `docs/project-agent-todo-contract.md` | Per-agent selected runnable todo without replacing the goal-level next action. | | `agent_workspace_guard_v1` | `loopx quota should-run`, selected todo and repository policy | Blocks repository delivery until the current peer uses a compliant worktree/branch. | @@ -47,6 +46,29 @@ them directly. | `delivery_outcome` | `loopx/delivery_outcome.py`, `loopx/history.py`, `loopx/status.py` | Machine-readable result tier: surface-only, outcome gap, outcome progress, or primary goal outcome. | | `rollback_packet_v0` | `docs/reference/protocols/rollback-packet-v0.md` | Compensating action record linking todo, commit, event, decision, external resource, and validation plan. | +### Candidate discovery and command retirement + +`todo suggest` and its `todo_suggestion_prompt_v0` packet are retired. The +command only emitted instructions for the current agent; it did not inspect +the repository or persist candidates. There is no replacement discovery +command, nested packet, or compatibility alias. + +Read existing work with `loopx todo list --goal-id --role agent +--status open --limit 50`. This is a bounded view: an open Todo is not proof of +current execution eligibility, and a limited view is not the complete frontier. +Keep the existing quota/Turn revalidation before selecting work. + +Ask the current project agent to analyze uncovered work using the relevant +repository evidence and existing Todos. Proposals remain advisory until the +user or authorized controller selects them for `loopx todo add`; analysis +does not grant execution authority. `todo plan` remains the existing Goal's +caller-invoked planning checkpoint, not a drop-in suggestion replacement. + +Scripts that parsed the retired prompt must remove that step and use their +existing agent interaction. The former Todo `--from` and `--trigger` options +are removed; `--limit` retains its existing `todo list` meaning. No persisted +Todo, journal or provider state migration is required. + ## Projection Protocol Projection protocol fields are read-only views. They may summarize, rank, and @@ -272,8 +294,6 @@ Partially aligned: - `task_graph_projection_v0` is specified but not yet a stable dashboard input. - Commit-to-todo linkage is convention-based through PR text, commit messages, todo evidence, and rollout refs. -- `todo suggest` produces a prompt packet for the user's agent; it is not a - fully autonomous repo analyzer. - External-resource partial success is not yet a generic protocol. Not yet aligned: diff --git a/docs/reference/protocols/todo-suggestion-prompt-v0.md b/docs/reference/protocols/todo-suggestion-prompt-v0.md deleted file mode 100644 index 559d6f9993..0000000000 --- a/docs/reference/protocols/todo-suggestion-prompt-v0.md +++ /dev/null @@ -1,67 +0,0 @@ -# todo_suggestion_prompt_v0 - -`todo_suggestion_prompt_v0` is a prompt contract for asking the user's current -project agent to produce a small candidate todo decision queue. - -LoopX does not analyze the repository in this path. LoopX only provides the -bounded task body, candidate schema, promotion policy, source lanes, and -frequency limits. The project agent reads the current repo and returns -`suggested_todos`; those candidates are not formal LoopX todos until the user -or primary controller promotes one. - -This is intentionally a manual, read-only discovery surface. It is retained -even without automatic product callers because an explicit operator request is -its product entrypoint. It is not a provider-default or TypeScript-migration -prerequisite, and it should not gain a dedicated write path unless a real -replacement interaction is first accepted. - -## Command - -```bash -loopx todo suggest --goal-id --from recent-repo --from loopx-deferred --limit 3 -``` - -Useful triggers: - -- post-connect onboarding, after the project has a valid LoopX state; -- explicit user request such as "what looks worth doing next?"; -- no runnable agent todo after status/quota inspection; -- material repo changes since the last candidate review. -- `quality-watch` cadence turns, where the agent should compare fresh signals - against the last known state and return candidates only for new or - still-uncovered evidence. - -Avoid running this on every heartbeat. The default limit is 3 and the hard cap -is 5. - -## Candidate Shape - -The agent's output should use a `suggested_todos` list. Each item uses -`suggested_todo_candidate_v0`: - -```json -{ - "schema_version": "suggested_todo_candidate_v0", - "candidate_id": "suggested_todo_repo_smoke_gap", - "title": "Add a smoke for the new setup path", - "why_now": "Recent docs changed the setup flow, but no smoke covers the wording.", - "evidence": ["README.md", "examples/project/project-prompt-smoke.py"], - "first_safe_action": "Inspect the existing setup smoke and draft one failing assertion.", - "requires_user_decision": false, - "risk": "low", - "value": "prevents onboarding regressions", - "confidence": "medium", - "suggested_owner_agent": "codex-main-control", - "promotion_preview": "loopx todo add --goal-id --role agent --text '...'" -} -``` - -## Rules - -- Candidate generation is read-only by default. -- A candidate is not a user todo. -- `requires_user_decision=true` is only for owner choice, protected access, - external action, or private material approval. -- The agent may return an empty list when evidence is weak or already covered. -- Promotion uses `loopx todo add` after explicit approval; this protocol does - not write active state. diff --git a/examples/cli-control-plane-command-modularization-smoke.py b/examples/cli-control-plane-command-modularization-smoke.py index 5c9ab132fa..17d74c9017 100644 --- a/examples/cli-control-plane-command-modularization-smoke.py +++ b/examples/cli-control-plane-command-modularization-smoke.py @@ -30,7 +30,8 @@ def run_cli(*args: str) -> subprocess.CompletedProcess[str]: def assert_contains(text: str, needle: str, label: str) -> None: - if needle not in text: + # Help wrapping can split words at hyphens; only whitespace is incidental here. + if "".join(needle.split()) not in "".join(text.split()): raise AssertionError(f"{label} missing {needle!r}") diff --git a/examples/control_plane/todo-suggestion-prompt-smoke.py b/examples/control_plane/todo-suggestion-prompt-smoke.py deleted file mode 100644 index 7d8a8ae45a..0000000000 --- a/examples/control_plane/todo-suggestion-prompt-smoke.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -"""Smoke-test agent-guided candidate todo suggestion prompts.""" - -from __future__ import annotations - -import json -import subprocess -import sys -import tempfile -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[2] -GOAL_ID = "todo-suggestion-goal" - - -def write_fixture(root: Path) -> tuple[Path, Path]: - project = root / "project" - runtime = root / "runtime" - state_file = project / ".codex" / "goals" / GOAL_ID / "ACTIVE_GOAL_STATE.md" - registry_path = project / ".loopx" / "registry.json" - state_file.parent.mkdir(parents=True) - state_file.write_text( - "---\n" - "status: active\n" - "updated_at: 2026-01-01T00:00:00+00:00\n" - "---\n\n" - "# Active Goal State\n\n" - "## Objective\n\n" - "Exercise todo suggestion prompt generation.\n", - encoding="utf-8", - ) - registry_path.parent.mkdir(parents=True) - registry_path.write_text( - json.dumps( - { - "schema_version": 1, - "updated_at": "2026-01-01T00:00:00+00:00", - "common_runtime_root": str(runtime), - "goals": [ - { - "id": GOAL_ID, - "domain": "todo-suggestion-fixture", - "status": "active", - "repo": str(project), - "state_file": ".codex/goals/todo-suggestion-goal/ACTIVE_GOAL_STATE.md", - "adapter": {"kind": "generic_project_goal_v0", "status": "connected"}, - "authority_sources": [], - } - ], - }, - ensure_ascii=False, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - return registry_path, state_file - - -def run_cli(registry_path: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [ - sys.executable, - "-m", - "loopx.cli", - "--registry", - str(registry_path), - *args, - ], - cwd=REPO_ROOT, - check=check, - text=True, - capture_output=True, - ) - - -def main() -> int: - with tempfile.TemporaryDirectory(prefix="loopx-todo-suggestion-smoke-") as tmp: - root = Path(tmp) - registry_path, state_file = write_fixture(root) - original_state = state_file.read_text(encoding="utf-8") - - result = run_cli( - registry_path, - "--format", - "json", - "todo", - "suggest", - "--goal-id", - GOAL_ID, - "--project", - ".", - "--agent-id", - "codex-main-control", - "--from", - "recent-repo", - "--from", - "loopx-deferred", - "--limit", - "9", - "--trigger", - "user-requested", - ) - payload = json.loads(result.stdout) - assert payload["ok"] is True, payload - assert payload["schema_version"] == "todo_suggestion_prompt_v0", payload - assert payload["mode"] == "agent_guided_candidate_todo_queue", payload - assert payload["analysis_owner"] == "user_project_agent", payload - assert payload["loopx_role"] == "prompt_contract_only", payload - assert payload["state_write_performed"] is False, payload - assert payload["formal_todos_written"] is False, payload - assert payload["effective_limit"] == 5, payload - assert payload["max_limit"] == 5, payload - assert payload["sources"] == ["recent-repo", "loopx-deferred"], payload - assert payload["candidate_queue_field"] == "suggested_todos", payload - assert payload["candidate_schema_version"] == "suggested_todo_candidate_v0", payload - assert payload["promotion_policy"]["do_not_execute_in_suggestion_turn"] is True, payload - task_body = payload["task_body"] - assert "LoopX is not analyzing the repository itself" in task_body, task_body - assert "Return at most 5 items in a `suggested_todos` list" in task_body, task_body - assert "Do not call `loopx todo add`" in task_body, task_body - assert "`promotion_preview`" in task_body, task_body - assert "`requires_user_decision=true`" in task_body, task_body - assert state_file.read_text(encoding="utf-8") == original_state - - watch_result = run_cli( - registry_path, - "--format", - "json", - "todo", - "suggest", - "--goal-id", - GOAL_ID, - "--agent-id", - "codex-product-capability", - "--from", - "failing-checks", - "--from", - "complexity-hotspots", - "--trigger", - "quality-watch", - ) - watch_payload = json.loads(watch_result.stdout) - assert watch_payload["ok"] is True, watch_payload - assert watch_payload["trigger"] == "quality-watch", watch_payload - assert watch_payload["sources"] == ["failing-checks", "complexity-hotspots"], watch_payload - assert watch_payload["formal_todos_written"] is False, watch_payload - watch_body = watch_payload["task_body"] - assert "Because this is a `quality-watch` turn" in watch_body, watch_body - assert "new or still-uncovered evidence" in watch_body, watch_body - assert "return an empty list with a short rationale" in watch_body, watch_body - assert "quality-watch" in watch_payload["frequency_policy"]["recommended_triggers"], watch_payload - assert state_file.read_text(encoding="utf-8") == original_state - - markdown = run_cli( - registry_path, - "todo", - "suggest", - "--goal-id", - GOAL_ID, - "--from", - "docs-smokes", - ).stdout - assert "LoopX Todo Suggestion Prompt" in markdown, markdown - assert "Agent Task Body" in markdown, markdown - assert "Suggested items are decision candidates" in markdown, markdown - assert state_file.read_text(encoding="utf-8") == original_state - - rejected = run_cli( - registry_path, - "--format", - "json", - "todo", - "add", - "--goal-id", - GOAL_ID, - "--role", - "agent", - "--text", - "Do something.", - "--agent-id", - "codex-main-control", - check=False, - ) - assert rejected.returncode == 1, rejected.stdout - error_payload = json.loads(rejected.stdout) - assert error_payload["error"].startswith( - "todo add does not support --agent-id for agent todos;" - ), error_payload - error = error_payload["error"] - assert "use --claimed-by " in error, error_payload - assert "omit both options to leave the todo unclaimed" in error, error_payload - - print("todo-suggestion-prompt-smoke: ok") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index afaf2ef9b1..2a681fdd49 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -25,10 +25,6 @@ from ..control_plane.work_items.semantic_replan_writeback import ( qualify_replan_writeback, ) -from ..todo_suggestion_prompt import ( - build_todo_suggestion_prompt_packet, - render_todo_suggestion_prompt_markdown, -) from ..control_plane.goals.task_planning import ( build_task_planning_packet, render_task_planning_packet, @@ -51,7 +47,6 @@ validate_todo_complete_options, validate_todo_list_options, validate_todo_project_markdown_options, - validate_todo_suggest_options, validate_todo_plan_options, validate_todo_supersede_options, validate_todo_update_options, @@ -200,9 +195,6 @@ def handle_todo_command( renderer = ( render_task_planning_packet if args.todo_command == "plan" - else - render_todo_suggestion_prompt_markdown - if args.todo_command == "suggest" else render_todo_markdown ) try: @@ -573,17 +565,6 @@ def handle_todo_command( **_todo_path_args(args), dry_run=not bool(args.execute), ) - elif args.todo_command == "suggest": - validate_todo_suggest_options(args) - payload = build_todo_suggestion_prompt_packet( - goal_id=args.goal_id, - project=Path(args.project).expanduser() if args.project else None, - agent_id=args.agent_id, - sources=args.suggestion_sources, - limit=args.todo_limit, - trigger=args.suggestion_trigger, - ) - payload["dry_run"] = True else: raise ValueError("unsupported todo command") except Exception as exc: diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index 8dcdc0b414..be0f6ed210 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -73,10 +73,8 @@ ("--next-excluded-agent", "next_excluded_agents"), ("--self-merged", "self_merged"), ("--agent-id", "agent_id"), - ("--from", "suggestion_sources"), ("--limit", "todo_limit"), ("--thin", "todo_thin"), - ("--trigger", "suggestion_trigger"), ("--state-file", "state_file"), ("--execute", "execute"), ("--provider-revision", "provider_revision"), @@ -458,15 +456,6 @@ def validate_todo_archive_completed_options(args: argparse.Namespace) -> None: raise ValueError(message) -def validate_todo_suggest_options(args: argparse.Namespace) -> None: - _validate_todo_option_subset( - args, - {"agent_id", "suggestion_sources", "todo_limit", "suggestion_trigger"}, - "todo suggest only accepts --goal-id, optional --project, --agent-id, " - "--from, --limit, --trigger, --dry-run, and --format; unsupported: ", - ) - - def validate_shared_todo_options(args: argparse.Namespace) -> None: agent_id_allowed_for_user_authoring = ( args.todo_command == "add" @@ -531,7 +520,7 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None: "--authority-reason is supported only by todo update/complete/supersede" ) if ( - args.todo_command not in {"suggest", "plan"} + args.todo_command != "plan" and args.agent_id and not agent_id_allowed_for_user_authoring and not agent_id_allowed_for_read @@ -546,7 +535,7 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None: ) raise ValueError( f"todo {args.todo_command} does not support --agent-id; --agent-id " - "scopes todo list/suggest, user-todo authoring, and lifecycle actor " + "scopes todo list, user-todo authoring, and lifecycle actor " "attribution only." ) if args.global_gate and not global_gate_allowed: @@ -564,16 +553,11 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None: "todo update accepts either --resume-when or --clear-resume-when, not both" ) if ( - args.todo_command != "suggest" - and (args.suggestion_sources or args.suggestion_trigger) - ): - raise ValueError("--from and --trigger are supported only by todo suggest") - if ( - args.todo_command not in {"suggest", "list"} + args.todo_command != "list" and args.todo_limit is not None ): raise ValueError( - "--limit is supported only by todo suggest and todo list" + "--limit is supported only by todo list" ) if args.todo_thin and args.todo_command != "list": raise ValueError("--thin is supported only by todo list") diff --git a/loopx/cli_commands/todo_event.py b/loopx/cli_commands/todo_event.py index 44855b2e76..392a6ad397 100644 --- a/loopx/cli_commands/todo_event.py +++ b/loopx/cli_commands/todo_event.py @@ -33,9 +33,7 @@ def todo_error_payload(args: argparse.Namespace, exc: Exception) -> dict[str, object]: payload: dict[str, object] = { "ok": False, - "dry_run": True - if args.todo_command == "suggest" - else not bool(args.execute) + "dry_run": not bool(args.execute) if args.todo_command in {"archive-completed", "project-markdown"} else bool(args.dry_run), "added": False, diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 43509b360d..a2882192d9 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -5,10 +5,6 @@ from ..control_plane.todos.contract import TODO_CONTINUATION_POLICY_VALUES from ..todos import ARCHIVE_COMPLETED_DEFAULT_MAX_ACTIVE_DONE -from ..todo_suggestion_prompt import ( - ALLOWED_TODO_SUGGESTION_SOURCES, - ALLOWED_TODO_SUGGESTION_TRIGGERS, -) from .todo_argument_validation import ( register_todo_linkage_arguments, register_todo_successor_creation_arguments, @@ -40,7 +36,6 @@ def register_todo_command( "complete", "supersede", "archive-completed", - "suggest", "plan", "project-markdown", ], @@ -49,7 +44,6 @@ def register_todo_command( "Use add to append a checkbox todo, claim to soft-claim by registered " "agent id, list to read projected todos, update/complete/supersede to transition by todo_id, or " "archive-completed to move older completed todos into Completed Work Archive. " - "Use suggest to generate an agent-facing candidate todo analysis prompt without writing state. " "Use plan with --text and --agent-id for the existing Goal's model planning checkpoint; the caller owns subsequent execution." ), ) @@ -430,25 +424,17 @@ def register_todo_command( "claim/update/complete/supersede, attribute the " "lifecycle actor; registered multi-agent goals require it unless an " "exact linked user_gate decision_scope supplies the typed owner/controller " - "override. For list/suggest, select the project agent lane. Agent todo " + "override. For list, select the project agent lane. Agent todo " "add intentionally does not accept this option; use --claimed-by to " "assign execution, or omit both options to leave the todo unclaimed." ), ) - todo_parser.add_argument( - "--from", - dest="suggestion_sources", - choices=ALLOWED_TODO_SUGGESTION_SOURCES, - action="append", - help="For todo suggest, include a source lane for agent analysis. Repeat for multiple lanes.", - ) todo_parser.add_argument( "--limit", dest="todo_limit", type=int, help=( - "For todo suggest, maximum candidate count; values above 5 are " - "clamped to 5. For todo list, explicit per-section cold-path cap: " + "For todo list, explicit per-section cold-path cap: " "keep the top N todos of each role section after filtering; must " "be an integer >= 1, and the payload discloses the truncation via " "explicit_limit." @@ -464,12 +450,6 @@ def register_todo_command( "lower but not expand that bound." ), ) - todo_parser.add_argument( - "--trigger", - dest="suggestion_trigger", - choices=ALLOWED_TODO_SUGGESTION_TRIGGERS, - help="For todo suggest, why this candidate queue is being requested.", - ) todo_parser.add_argument("--project", help="Project root. Defaults to the registry goal repo.") todo_parser.add_argument("--state-file", help="Active goal state path. Defaults to the registry goal state_file.") todo_parser.add_argument("--dry-run", action="store_true", help="Preview the active-state edit without writing.") diff --git a/loopx/todo_suggestion_prompt.py b/loopx/todo_suggestion_prompt.py deleted file mode 100644 index 02fa886ad8..0000000000 --- a/loopx/todo_suggestion_prompt.py +++ /dev/null @@ -1,204 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - - -TODO_SUGGESTION_PROMPT_SCHEMA_VERSION = "todo_suggestion_prompt_v0" -SUGGESTED_TODO_CANDIDATE_SCHEMA_VERSION = "suggested_todo_candidate_v0" -DEFAULT_SUGGESTION_LIMIT = 3 -MAX_SUGGESTION_LIMIT = 5 -ALLOWED_TODO_SUGGESTION_SOURCES = ( - "recent-repo", - "issues-prs", - "failing-checks", - "todo-markers", - "complexity-hotspots", - "loopx-deferred", - "docs-smokes", -) -DEFAULT_TODO_SUGGESTION_SOURCES = ( - "recent-repo", - "issues-prs", - "failing-checks", - "todo-markers", - "loopx-deferred", - "docs-smokes", -) -ALLOWED_TODO_SUGGESTION_TRIGGERS = ( - "user-requested", - "post-connect", - "no-runnable-todo", - "repo-changed", - "quality-watch", -) - - -def normalize_suggestion_sources(sources: list[str] | None) -> list[str]: - raw_sources = sources or list(DEFAULT_TODO_SUGGESTION_SOURCES) - normalized: list[str] = [] - for source in raw_sources: - compact = str(source or "").strip() - if compact not in ALLOWED_TODO_SUGGESTION_SOURCES: - raise ValueError(f"unsupported todo suggestion source: {compact}") - if compact not in normalized: - normalized.append(compact) - return normalized - - -def effective_suggestion_limit(limit: int | None) -> int: - if limit is None: - return DEFAULT_SUGGESTION_LIMIT - if limit < 1: - raise ValueError("todo suggestion --limit must be at least 1") - return min(limit, MAX_SUGGESTION_LIMIT) - - -def _project_hint(project: Path | None) -> str: - if project is None: - return "current project root" - return str(project) - - -def build_todo_suggestion_prompt_packet( - *, - goal_id: str, - project: Path | None = None, - agent_id: str | None = None, - sources: list[str] | None = None, - limit: int | None = None, - trigger: str | None = None, -) -> dict[str, Any]: - """Build an agent-facing prompt for candidate todo discovery. - - LoopX intentionally does not inspect the repository here. The packet tells - the user's current project agent how to perform a bounded read-only analysis - and how to return a decision queue without writing formal LoopX todos. - """ - - if not goal_id.strip(): - raise ValueError("goal_id is required") - selected_sources = normalize_suggestion_sources(sources) - selected_trigger = trigger or "user-requested" - if selected_trigger not in ALLOWED_TODO_SUGGESTION_TRIGGERS: - raise ValueError(f"unsupported todo suggestion trigger: {selected_trigger}") - selected_limit = effective_suggestion_limit(limit) - requested_limit = limit if limit is not None else DEFAULT_SUGGESTION_LIMIT - project_hint = _project_hint(project) - agent_label = agent_id or "current project agent" - source_text = ", ".join(selected_sources) - quality_watch_note = ( - "\n\nBecause this is a `quality-watch` turn, compare fresh signals against the " - "last known state first. Return candidates only for new or still-uncovered " - "evidence; otherwise return an empty list with a short rationale." - if selected_trigger == "quality-watch" - else "" - ) - - task_body = f"""Generate a bounded candidate todo decision queue for LoopX goal `{goal_id}`. - -You are `{agent_label}` operating in the current project. LoopX is only giving -you the analysis contract; LoopX is not analyzing the repository itself. - -Read the current repo and recent project signals in read-only mode. Use these -source lanes when available: {source_text}. Prefer public-safe evidence such as -repo-relative paths, commit or PR identifiers, failing check names, TODO/FIXME -locations, LoopX deferred todo ids, and docs/smoke gaps. Do not include raw logs, -credentials, private material, local absolute paths, issue bodies, or chat -transcripts. - -Return at most {selected_limit} items in a `suggested_todos` list. These are -decision candidates, not formal LoopX todos. Do not call `loopx todo add`, -`loopx todo update`, `loopx todo complete`, or edit LoopX state in this turn. -If the evidence is weak or already covered by existing runnable todos, return an -empty list with a short rationale. -{quality_watch_note} - -Each candidate must use schema `{SUGGESTED_TODO_CANDIDATE_SCHEMA_VERSION}` and -include: `candidate_id`, `title`, `why_now`, `evidence`, `first_safe_action`, -`requires_user_decision`, `risk`, `value`, `confidence`, `suggested_owner_agent`, -and `promotion_preview`. `promotion_preview` should be a public-safe -`loopx todo add ...` command draft, but it must not be executed unless the user -or primary controller explicitly promotes the candidate. - -Keep user gates separate: a candidate is not a user todo. Only set -`requires_user_decision=true` when the first safe action genuinely needs owner -choice, protected access, external action, or private material approval.""" - - return { - "ok": True, - "schema_version": TODO_SUGGESTION_PROMPT_SCHEMA_VERSION, - "goal_id": goal_id, - "mode": "agent_guided_candidate_todo_queue", - "analysis_owner": "user_project_agent", - "loopx_role": "prompt_contract_only", - "agent_id": agent_id, - "project": project_hint, - "trigger": selected_trigger, - "sources": selected_sources, - "requested_limit": requested_limit, - "effective_limit": selected_limit, - "max_limit": MAX_SUGGESTION_LIMIT, - "state_write_performed": False, - "formal_todos_written": False, - "candidate_queue_field": "suggested_todos", - "candidate_schema_version": SUGGESTED_TODO_CANDIDATE_SCHEMA_VERSION, - "task_body": task_body, - "expected_candidate_fields": [ - "candidate_id", - "title", - "why_now", - "evidence", - "first_safe_action", - "requires_user_decision", - "risk", - "value", - "confidence", - "suggested_owner_agent", - "promotion_preview", - ], - "promotion_policy": { - "default_state": "candidate_only", - "who_may_promote": ["user", "primary_controller"], - "promotion_command": "loopx todo add --goal-id --role agent --text ", - "do_not_execute_in_suggestion_turn": True, - }, - "frequency_policy": { - "default_limit": DEFAULT_SUGGESTION_LIMIT, - "hard_limit": MAX_SUGGESTION_LIMIT, - "recommended_triggers": list(ALLOWED_TODO_SUGGESTION_TRIGGERS), - "avoid_every_heartbeat": True, - }, - } - - -def render_todo_suggestion_prompt_markdown(payload: dict[str, Any]) -> str: - lines = [ - "# LoopX Todo Suggestion Prompt", - "", - f"- ok: `{payload.get('ok')}`", - f"- schema_version: `{payload.get('schema_version')}`", - f"- goal_id: `{payload.get('goal_id')}`", - f"- mode: `{payload.get('mode')}`", - f"- analysis_owner: `{payload.get('analysis_owner')}`", - f"- loopx_role: `{payload.get('loopx_role')}`", - f"- state_write_performed: `{payload.get('state_write_performed')}`", - f"- formal_todos_written: `{payload.get('formal_todos_written')}`", - f"- effective_limit: `{payload.get('effective_limit')}`", - f"- sources: `{', '.join(payload.get('sources') or [])}`", - "", - "## Agent Task Body", - "", - "```text", - str(payload.get("task_body") or "").rstrip(), - "```", - "", - "## Promotion Policy", - "", - "- Suggested items are decision candidates, not formal LoopX todos.", - "- Only the user or primary controller should promote a candidate.", - "- Promotion uses `loopx todo add` after explicit approval.", - ] - if payload.get("error"): - lines.extend(["", "## Error", "", str(payload.get("error"))]) - return "\n".join(lines) + "\n" diff --git a/tests/test_cli_argument_diagnostics.py b/tests/test_cli_argument_diagnostics.py index 2b2b966a43..5302aeb211 100644 --- a/tests/test_cli_argument_diagnostics.py +++ b/tests/test_cli_argument_diagnostics.py @@ -15,7 +15,6 @@ validate_todo_claim_options, validate_todo_complete_options, validate_todo_list_options, - validate_todo_suggest_options, validate_todo_supersede_options, validate_todo_update_options, validate_shared_todo_options, @@ -23,28 +22,18 @@ from loopx.control_plane.work_items.task_lease import TaskLeaseError -def test_todo_handler_expands_shared_paths_and_keeps_suggest_project_only( +def test_todo_list_handler_expands_shared_paths( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("HOME", str(tmp_path)) captured_list: dict[str, object] = {} - captured_suggest: dict[str, object] = {} def fake_list_goal_todos(**kwargs: object) -> dict[str, object]: captured_list.update(kwargs) return {"ok": True, "dry_run": True} - def fake_suggestion_packet(**kwargs: object) -> dict[str, object]: - captured_suggest.update(kwargs) - return {"ok": True} - monkeypatch.setattr(todo_command, "list_goal_todos", fake_list_goal_todos) - monkeypatch.setattr( - todo_command, - "build_todo_suggestion_prompt_packet", - fake_suggestion_packet, - ) common = { "registry_path": tmp_path / "registry.json", "runtime_root_arg": None, @@ -68,20 +57,6 @@ def fake_suggestion_packet(**kwargs: object) -> dict[str, object]: assert captured_list["project"] == tmp_path / "project" assert captured_list["state_file"] == tmp_path / "ACTIVE_GOAL_STATE.md" - suggest_args = build_parser().parse_args( - [ - "todo", - "suggest", - "--goal-id", - "example-goal", - "--project", - "~/project", - ] - ) - assert todo_command.handle_todo_command(suggest_args, **common) == 0 - assert captured_suggest["project"] == tmp_path / "project" - assert "state_file" not in captured_suggest - def test_todo_requires_explicit_command_without_mutating_state( monkeypatch: pytest.MonkeyPatch, @@ -977,66 +952,33 @@ def test_todo_archive_completed_validation_accepts_role_and_limit() -> None: validate_todo_archive_completed_options(args) -def test_todo_suggest_validation_preserves_exact_unsupported_diagnostic( +@pytest.mark.parametrize("command", ["capture-followups", "suggest"]) +def test_retired_todo_command_is_rejected_before_state_access( + command: str, + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - exit_code = main( - [ - "--format", - "json", - "todo", - "suggest", - "--goal-id", - "example-goal", - "--todo-id", - "todo_example", - "--note", - "not accepted", - ] - ) + def unexpected_dispatch(*_args: object, **_kwargs: object) -> None: + pytest.fail("retired command reached the Todo handler") - assert exit_code == 1 - payload = json.loads(capsys.readouterr().out) - assert payload["error"] == ( - "todo suggest only accepts --goal-id, optional --project, --agent-id, " - "--from, --limit, --trigger, --dry-run, and --format; unsupported: " - "--todo-id, --note" - ) - - -def test_todo_suggest_validation_accepts_suggestion_scope_options() -> None: - args = build_parser().parse_args( - [ - "todo", - "suggest", - "--goal-id", - "example-goal", - "--agent-id", - "codex-example", - "--from", - "recent-repo", - "--limit", - "3", - "--trigger", - "quality-watch", - ] - ) + monkeypatch.setattr(todo_command, "handle_todo_command", unexpected_dispatch) + with pytest.raises(SystemExit) as exc_info: + main(["todo", command, "--goal-id", "example-goal"]) - validate_todo_suggest_options(args) + assert exc_info.value.code == 2 + diagnostic = capsys.readouterr().err + assert "invalid choice" in diagnostic + assert command in diagnostic -def test_todo_capture_followups_is_not_a_registered_command( - capsys: pytest.CaptureFixture[str], +@pytest.mark.parametrize("option,value", [("--from", "recent-repo"), ("--trigger", "quality-watch")]) +def test_retired_suggestion_options_are_not_silently_accepted( + option: str, value: str, capsys: pytest.CaptureFixture[str], ) -> None: with pytest.raises(SystemExit) as exc_info: - build_parser().parse_args( - ["todo", "capture-followups", "--goal-id", "example-goal"] - ) - + main(["todo", "list", "--goal-id", "example-goal", option, value]) assert exc_info.value.code == 2 - diagnostic = capsys.readouterr().err - assert "invalid choice" in diagnostic - assert "capture-followups" in diagnostic + assert "unrecognized arguments" in capsys.readouterr().err @pytest.mark.parametrize(