diff --git a/AGENTS.md b/AGENTS.md index da02139..f90b9a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,10 @@ Treat `AGENTS.md` as part of the codebase's invariants, not documentation. A dri [--live-migrations-dir DIR] [--in-repo-taste [PATH]] [--force]` - Taste: `continuous-refactoring taste [--global] [--interview|--upgrade|--refine] - [--with codex|claude --model --effort ] + [--with codex|claude --model ] [--force]` + Active taste agent modes require `--with` and `--model`; the taste agent + always runs at fixed `medium` effort. - Run once: `continuous-refactoring run-once --with codex|claude --model [common targeting/validation flags]` - Run loop: `continuous-refactoring run --with codex|claude --model @@ -37,16 +39,14 @@ Treat `AGENTS.md` as part of the codebase's invariants, not documentation. A dri - Upgrade config: `continuous-refactoring upgrade` - Inspect migrations: `continuous-refactoring migration list [--status planning|ready|in-progress|skipped|done] - [--awaiting-review]` / + [--awaiting-review] [--no-headers]` / `continuous-refactoring migration doctor ` / `continuous-refactoring migration doctor --all` - Review migrations: `continuous-refactoring migration review - --with codex|claude --model --effort ` - (top-level `review list` / `review perform --with ... --model ... - --effort ...` remain compatibility wrappers) + --with codex|claude --model ` - Refine migration planning: `continuous-refactoring migration refine (--message |--file ) --with codex|claude --model - --effort [--show-agent-logs]` + [--show-agent-logs]` No lint, no typecheck, no formatter, no pre-commit. GitHub Actions `Test` runs `uv run pytest`. **Pytest is the only code gate.** GitHub Actions @@ -126,20 +126,20 @@ active phase explicitly names `loop.py` in scope. - **Driver owns commits** (`refactor_attempts.py:_finalize_commit()`, called from `loop.py`) — if an agent commits mid-attempt, driver does `git reset --soft head_before` and re-commits with its own message. - **Migration scheduling split** (`migrations.py`, `loop.py`, `phases.py`) — `last_touch` is activity bookkeeping, not the 6-hour retry gate. Deferred/blocked migrations set `cooldown_until`; successful phase completion clears deferral markers so the next ready phase can run immediately. - **Migration tick deferral writes** (`migration_tick.py`) — ready-check deferrals are queued while scanning candidates and saved only when the tick finds no executable phase or blocks for human review. Do not save a deferred manifest before checking later candidates; that dirties the worktree and can make ready-checks reject runnable phases. -- **Migration visibility + consistency gate** (`migration_consistency.py`, `migration_tick.py`, `loop.py`, `review_cli.py`) — candidate scans use `iter_visible_migration_dirs()` so hidden/dotted/internal/symlink dirs are invisible to tick/review list. Before ready-check, `execution-gate` consistency errors block phase execution; `info`/`warning` never block. +- **Migration visibility + consistency gate** (`migration_consistency.py`, `migration_tick.py`, `loop.py`, `review_cli.py`) — candidate scans use `iter_visible_migration_dirs()` so hidden/dotted/internal/symlink dirs are invisible to tick/list/review commands. Before ready-check, `execution-gate` consistency errors block phase execution; `info`/`warning` never block. - **Manifest codec boundary** (`migration_manifest_codec.py`, `migrations.py`) — codec owns legacy `ready_when`, legacy integer `current_phase`, duplicate phase-name rejection, and saved JSON formatting. `load_manifest()` / `save_manifest()` own filesystem and JSON boundary errors. -- **Planning state codec boundary** (`planning_state.py`, `planning.py`) — `.planning/state.json` is valid only when completed steps replay through the branching planning graph to `next_step`; recorded outputs must be repo-relative files inside the migration directory. User refinement feedback is durable state, and append-only `revision_base_step_counts` anchors let unexecuted ready migrations reuse `revise` after terminal ready decisions; legacy `revision_base_step_count` decodes as one anchor. Persist accepted step stdout after the step is validated; do not add durable fields for failed current-step output. +- **Planning state codec boundary** (`planning_state.py`, `planning.py`) — `.planning/state.json` is valid only when completed steps replay through the branching planning graph to `next_step`; recorded outputs must be repo-relative files inside the migration directory. User refinement feedback is durable state, and append-only `revision_base_step_counts` anchors let refinement reuse `revise` from review cursors or unexecuted ready terminal decisions; legacy `revision_base_step_count` decodes as one anchor. Persist accepted step stdout after the step is validated; do not add durable fields for failed current-step output. - **Planning publish transaction** (`planning_publish.py`) — publish copies the complete workspace snapshot to `__transactions__//staged`, validates it, checks same-device and `base_snapshot_id`, moves live to `rollback`, moves staged live, validates live, then deletes rollback. On post-rollback failure, move bad live to `failed` before restoring rollback. Transaction directories are invisible to scheduling/list candidates but visible to `migration doctor --all`. Do not bypass the lock or dirty-live check. - **One-step planning engine** (`planning.py`) — product planning entry points call `run_next_planning_step()` so one action runs exactly `PlanningState.next_step`, records accepted stdout/state in an off-live workspace, and publishes through `planning_publish.py`. Failed current-step output is never durable resume input. `run_planning` is intentionally not package-exported. - **Planning resume scheduling** (`migration_tick.py`, `loop.py`, `routing_pipeline.py`) — normal automation runs one eligible `status: planning` step before ready/in-progress phase ticks and before source-target routing. Missing or invalid `.planning/state.json` blocks automation with planning failure evidence; `status: planning` must never enter phase ready-check or phase execution. - **Focused planning reselection** (`loop.py`, `migration_tick.py`) — focused mode tracks planning migrations abandoned with `new-target` only in memory for the current run, skips them while another planning or phase candidate is eligible, and retries them only when no alternative remains. Do not persist this as `cooldown_until`; planning step failure is not a durable readiness deferral. -- **Review CLI boundary** (`cli.py`, `review_cli.py`) — `cli.py` owns parser wiring; staged migration review internals live in `review_cli.py`, publish only through `planning_publish.py`, and stay internal/out of package-root `_SUBMODULES`. Top-level `review perform` is only a compatibility wrapper around this path. +- **Review CLI boundary** (`cli.py`, `review_cli.py`) — `cli.py` owns parser wiring; staged migration review internals live in `review_cli.py`, publish only through `planning_publish.py`, and stay internal/out of package-root `_SUBMODULES`. Review mutation is only exposed through `migration review`. - **Migration CLI boundary** (`cli.py`, `migration_cli.py`) — `cli.py` owns parser wiring only; `migration_cli.py` owns namespace dispatch, read-only list/doctor behavior, and the contained slug/path resolver used by mutation commands. Mutating subcommands delegate their internals to focused modules such as `review_cli.py` or the planning refine entry point. Resolver targets must stay direct visible children of the configured live migrations root and reject symlink, outside, parent-traversal, and ambiguous paths. -- **Human-review gating** (`planning.py`, `migration_tick.py`, `review_cli.py`) — migrations with `awaiting_human_review=true` must be invisible to automated migration ticks/ready-checks until canonical `migration review` clears the flag through staged publish; top-level `review perform` routes to the same compatibility path. `migration refine` may reopen an unexecuted ready migration to planning, but it is user feedback, not review approval. +- **Human-review gating** (`planning.py`, `migration_tick.py`, `review_cli.py`) — migrations with `awaiting_human_review=true` must be invisible to automated migration ticks/ready-checks until canonical `migration review` clears the flag through staged publish. `migration refine` may reopen an unexecuted ready migration to planning, but it is user feedback, not review approval. - **Migration terminology split** (`migrations.py`, `planning.py`, `prompts.py`) — manifest `precondition` gates phase start; phase markdown `## Definition of Done` governs completion. - **Run-level baseline validation** (`loop.py`) — `run-once`, `run`, and `--focus-on-live-migrations` run the configured validation command after the clean-worktree check and before routing/refactoring. A red baseline stops as `baseline_failed`, not migration human review. - **Phase execution validation gate** (`phases.py`, `prompts.py`, `loop.py`) — a migration phase is complete only after host-side full validation passes. `execute_phase()` retries validation-red attempts from `head_before` up to the effective `--max-attempts` budget, and the phase prompt must include the literal configured validation command plus the phase file's Definition of Done as the completion contract. -- **Effort budgeting** (`effort.py`, `loop.py`, `migration_tick.py`, `planning.py`) — `run` / `run-once` default to `--default-effort low` and `--max-allowed-effort xhigh`; there is no `--effort` alias on those commands. Target `effort-override` changes that target's default but is still capped. Migration `required_effort` above the cap defers the phase without failing the run. +- **Effort budgeting** (`effort.py`, `loop.py`, `migration_tick.py`, `planning.py`) — `run` / `run-once` default to `--default-effort low` and `--max-allowed-effort xhigh`; there is no `--effort` alias on those commands. Target `effort-override` changes that target's default but is still capped. Migration `required_effort` above the cap defers the phase without failing the run. Manual `migration review` and `migration refine` operations use fixed internal `high` effort. Taste agent actions do not accept `--effort`; they always use fixed `medium` effort. - **Taste injection** — every prompt includes a `## Taste` section. `tests/test_prompts.py` enforces this via `_TASTE_INJECTED_PROMPTS`. Do not drop it. - **Taste read boundary** (`config.py`, `cli.py`, `loop.py`) — `load_taste()` translates unreadable project/global taste reads into `ContinuousRefactorError`; CLI stale-taste checks and loop taste loading must treat that boundary failure as non-fatal and skip/fall back instead of leaking raw `OSError`/`PermissionError`. - **Repo-local taste routing** (`config.py`, `cli.py`) — `ProjectEntry.repo_taste_path` is stored repo-relative in the XDG manifest and resolved through `resolve_project_taste_path()`. Keep `init`, `taste`, stale warnings, and run prompt loading on that helper so the active taste path does not drift. diff --git a/README.md b/README.md index 802c7c4..cb88520 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ uv pip install -e . That gives you the `continuous-refactoring` command. +The CLI itself can be installed without `uv`, but the default validation command +is `uv run pytest`. Pass `--validation-command pytest` or a project-specific +script when the target repo does not use `uv`. + Maintainers: see the [release checklist](https://github.com/bigH/continuous-refactoring/blob/main/docs/release.md). ## Fastest way to get one refactor @@ -101,8 +105,8 @@ continuous-refactoring init --in-repo-taste # 2. (Optional) Write your refactoring taste — either edit the file, have an agent interview you, # or refine an existing draft collaboratively -continuous-refactoring taste --interview --with codex --model gpt-5 --effort high -continuous-refactoring taste --refine --with codex --model gpt-5 --effort high +continuous-refactoring taste --interview --with codex --model gpt-5 +continuous-refactoring taste --refine --with codex --model gpt-5 # 3. Do one pass continuous-refactoring run-once \ @@ -118,22 +122,23 @@ continuous-refactoring run \ --sleep 5 ``` +Active taste agent modes require `--with` and `--model`; taste agent sessions +always run at fixed `medium` effort. + ## Subcommands | Command | What it does | |---|---| | `init` | Registers this directory as a project, creates a default `taste.md`, and can store `--live-migrations-dir` or `--in-repo-taste`. | -| `taste` | Prints the active taste file path. Add `--interview` to have an agent author it, `--refine` to iteratively improve an existing taste doc, `--upgrade` to refresh stale taste dimensions, `--global` for the shared file, and `--force` to let `--interview` overwrite custom content after writing a `.bak`. | +| `taste` | Prints the active taste file path. Add `--interview` to have an agent author it, `--refine` to iteratively improve an existing taste doc, `--upgrade` to refresh stale taste dimensions, `--global` for the shared file, and `--force` to let `--interview` overwrite custom content after writing a `.bak`. Active agent modes require `--with` and `--model`, then run at fixed `medium` effort. | | `run-once` | Single pass on one resolved target. No retry. If there is a diff and validation passes, it commits locally and prints the diffstat. | | `run` | The loop. Iterates refactor actions, retries on failure, and commits successful changes locally. Add `--focus-on-live-migrations` to bypass targeting and work only on eligible live migrations. | | `upgrade` | Checks that the global config manifest is current, rewrites it idempotently, and warns if the global taste file is stale. | -| `migration list` | Lists visible migrations. Add `--status ` or `--awaiting-review` to filter. | +| `migration list` | Lists visible migrations as TSV with headers. Add `--status ` or `--awaiting-review` to filter, or `--no-headers` for parsing. | | `migration doctor ` | Validates one visible migration's consistency. | | `migration doctor --all` | Validates every visible migration plus internal transaction state. | -| `migration review ` | Starts staged review for a migration awaiting human review. Requires `--with`, `--model`, and `--effort`. | -| `migration refine ` | Records feedback for a planning or unexecuted ready migration and runs one staged planning revision. Requires `--message ` or `--file `, plus `--with`, `--model`, and `--effort`; add `--show-agent-logs` to mirror the planning agent. | - -Legacy `review list` and `review perform ` remain compatibility aliases; prefer `migration list --awaiting-review` and `migration review`. +| `migration review ` | Starts staged review for a migration awaiting human review. Requires `--with` and `--model`; review runs at fixed internal `high` effort. | +| `migration refine ` | Records feedback for a planning or unexecuted ready migration and runs one staged planning revision. Requires `--message ` or `--file `, plus `--with` and `--model`; refine runs at fixed internal `high` effort. Add `--show-agent-logs` to mirror the planning agent. | ## Targeting / Useful flags @@ -151,8 +156,9 @@ These flags are not mutually exclusive, but only the highest-priority populated - `--scope-instruction "clean up the auth module"` — extra free-text scoping. If selected file patterns resolve nothing, this becomes the useful fallback context. If `--globs` or `--extensions` match no tracked files and there is no -`--scope-instruction`, `run` completes successfully with zero refactor actions. -`--paths` is literal input and is not filtered through `git ls-files`. +`--scope-instruction`, `run` completes successfully with zero refactor actions; +`run-once` falls back to a no-file `general refactoring` target. `--paths` is +literal input and is not filtered through `git ls-files`. If you provide none of `--targets`, `--globs`, `--extensions`, or `--paths`, then `run` and `run-once` require `--scope-instruction`; the driver still @@ -163,12 +169,13 @@ scope text as context for that target. - `init --live-migrations-dir PATH` — enables the larger-refactoring workflow for this project. The path is stored repo-relative in the project registry and created if missing. - `init --in-repo-taste [PATH]` — stores this project's taste file in the repo and remembers the repo-relative path. Defaults to `.continuous-refactoring/taste.md`; re-run `init --in-repo-taste ...` to choose a different path. -- `migration list` — shows visible migrations; `--awaiting-review` narrows to human-review handoffs. +- `migration list` — shows visible migrations as TSV with headers; `--awaiting-review` narrows to human-review handoffs and `--no-headers` keeps parser-friendly rows only. - `migration doctor ` / `migration doctor --all` — read-only consistency checks. Doctor reports problems; it does not repair them. -- `migration review --with ... --model ... --effort ...` — resolves an `awaiting_human_review` migration through a staged workspace. -- `migration refine (--message |--file ) --with ... --model ... --effort ... [--show-agent-logs]` — adds user feedback to a planning or unexecuted ready migration and resumes planning through the `revise` step when reopening ready work. -- `taste --refine` — opens a collaborative editing session for the taste file. The agent keeps refining until you tell it to write, then the session ends automatically after the settled write. -- `taste --upgrade` — re-interviews for taste dimensions added since your last version. No-op when already current; use `taste --refine` if you want to rework the doc anyway. +- `migration review --with ... --model ...` — resolves an `awaiting_human_review` migration through a staged workspace at fixed internal `high` effort. +- `migration refine (--message |--file ) --with ... --model ... [--show-agent-logs]` — adds user feedback to a planning or unexecuted ready migration and resumes planning through the `revise` step when reopening ready work at fixed internal `high` effort. +- `taste --refine --with ... --model ...` — opens a collaborative editing session for the taste file. The agent keeps refining until you tell it to write, then the session ends automatically after the settled write. +- `taste --upgrade --with ... --model ...` — re-interviews for taste dimensions added since your last version. No-op when already current; use `taste --refine` if you want to rework the doc anyway. +- Taste agent sessions always use fixed `medium` effort. - `taste --force` — only applies to `--interview`; it allows a customized taste file to be overwritten after backing it up to `taste.md.bak`. Canonical migration commands: @@ -177,11 +184,12 @@ Canonical migration commands: continuous-refactoring migration list continuous-refactoring migration list --status planning continuous-refactoring migration list --awaiting-review +continuous-refactoring migration list --no-headers continuous-refactoring migration doctor continuous-refactoring migration doctor --all -continuous-refactoring migration review --with codex --model gpt-5 --effort high -continuous-refactoring migration refine --message "split the risky phase" --with codex --model gpt-5 --effort high -continuous-refactoring migration refine --file feedback.md --with codex --model gpt-5 --effort high +continuous-refactoring migration review --with codex --model gpt-5 +continuous-refactoring migration refine --message "split the risky phase" --with codex --model gpt-5 +continuous-refactoring migration refine --file feedback.md --with codex --model gpt-5 ``` ### Shared `run` / `run-once` flags @@ -190,7 +198,7 @@ continuous-refactoring migration refine --file feedback.md --with - `--default-effort` — default effort for run calls. Defaults to `low`. Valid labels are `low`, `medium`, `high`, `xhigh`. - `--max-allowed-effort` — cap for target overrides and migration escalation. Defaults to `xhigh`. - `--repo-root PATH` — repository root; defaults to the current directory. -- `--validation-command` — defaults to `uv run pytest`. Swap it for whatever keeps your repo honest. +- `--validation-command` — defaults to `uv run pytest`. This is parsed with `shlex.split` and run without a shell, so simple commands like `pytest -q` work, but shell syntax such as `cmd && cmd`, pipes, redirects, or leading `VAR=value` assignments is not interpreted. Put compound validation in a script. - `--timeout` — per-agent-call timeout in seconds. - `--show-agent-logs` / `--show-command-logs` — mirror output to your terminal instead of just logging. - `--refactoring-prompt` — override the default refactoring prompt. @@ -219,9 +227,9 @@ continuous-refactoring migration refine --file feedback.md --with Each run writes to `$TMPDIR/continuous-refactoring//`: - `summary.json` — rolling status, counts, per-attempt stats -- `events.jsonl` — structured event log with call roles such as `classify`, - `planning.`, `phase.ready-check`, `phase.execute`, and - `phase.validation` +- `events.jsonl` — structured event log with call roles such as + `scope-expansion`, `classify`, `planning.`, `planning.publish`, + `phase.ready-check`, `phase.execute`, and `phase.validation` - `run.log` — human-readable log - `attempt-NNN/[retry-NN/]refactor/` — per-attempt agent + test stdout/stderr - `baseline/initial/` — baseline validation stdout/stderr before work starts @@ -248,7 +256,7 @@ The taste file is a short bullet list of your refactoring preferences. It gets i - Project taste: `~/.local/share/continuous-refactoring/projects//taste.md`, or the repo-local path chosen with `init --in-repo-taste [PATH]` - Global taste: `~/.local/share/continuous-refactoring/global/taste.md` -Project taste wins over global. Use `taste` to print the active path, `taste --interview` to bootstrap one, `taste --refine` to rework it with an agent, or edit the file directly any time. +Project taste wins over global. Use `taste` to print the active path, `taste --interview --with ... --model ...` to bootstrap one, `taste --refine --with ... --model ...` to rework it with an agent, or edit the file directly any time. ## Larger refactorings @@ -323,7 +331,7 @@ Before executing a phase, a ready-check agent verifies that the current phase pr - **ready: yes** — phase executes; on green tests, the phase is marked done, any prior deferral markers are cleared, and the migration advances immediately to the next phase. - **ready: no** — manifest activity is bumped, a retry cooldown is started, and a future `wake_up_on` is recorded when needed; the tick moves on. -- **ready: unverifiable** — the migration is flagged `awaiting_human_review` and put on cooldown. Automated migration ticks skip flagged migrations until review clears the flag. Use `migration list --awaiting-review` to find it and `migration review --with ... --model ... --effort ...` to resolve it interactively. +- **ready: unverifiable** — the migration is marked `awaiting_human_review` and put on cooldown. Automated migration ticks skip migrations awaiting human review until review clears the flag. Use `migration list --awaiting-review` to find it and `migration review --with ... --model ...` to resolve it interactively. Human-facing migration references use the relative phase spec path, for example `phase-2-failure-report.md`. The manifest cursor stores the phase `name`, not a numeric index. diff --git a/docs/release.md b/docs/release.md index 6938ffc..9303066 100644 --- a/docs/release.md +++ b/docs/release.md @@ -54,8 +54,9 @@ bumps: YAML cannot enforce these repository settings: -1. In GitHub branch protection or rulesets, require the `validate` status check - from the `PR Title` workflow before merging to `main`. +1. In GitHub branch protection or rulesets, require the `pytest` status check + from the `Test` workflow and the `validate` status check from the `PR Title` + workflow before merging to `main`. 2. Add a repository secret named `RELEASE_PLEASE_TOKEN` for Release Please. A fine-grained token needs Contents read/write and Pull requests read/write for `bigH/continuous-refactoring`. Without this secret, the workflow falls back diff --git a/src/continuous_refactoring/cli.py b/src/continuous_refactoring/cli.py index 290c380..1d50caa 100644 --- a/src/continuous_refactoring/cli.py +++ b/src/continuous_refactoring/cli.py @@ -32,7 +32,6 @@ ) from continuous_refactoring.migration_cli import handle_migration from continuous_refactoring.migrations import MIGRATION_STATUSES -from continuous_refactoring.review_cli import handle_review _PACKAGE_DISTRIBUTION = "continuous-refactoring" _TASTE_WARNING = "warning: taste out of date — run `continuous-refactoring taste --upgrade`" @@ -40,6 +39,7 @@ "warning: global taste is out of date — " "run 'continuous-refactoring taste --upgrade' to update." ) +_TASTE_AGENT_EFFORT = "medium" def _version_banner() -> str: @@ -155,6 +155,10 @@ def _add_taste_parser(subparsers: argparse._SubParsersAction) -> None: taste_parser = subparsers.add_parser( "taste", help="Manage refactoring taste files.", + description=( + "Manage project or global taste files. Agent-backed modes require " + "--with and --model." + ), ) taste_parser.set_defaults(handler=_handle_taste) taste_parser.add_argument( @@ -191,11 +195,6 @@ def _add_taste_parser(subparsers: argparse._SubParsersAction) -> None: default=None, help="Model name for --interview, --upgrade, or --refine.", ) - taste_parser.add_argument( - "--effort", - default=None, - help="Effort level for --interview, --upgrade, or --refine.", - ) taste_parser.add_argument( "--force", action="store_true", @@ -206,7 +205,8 @@ def _add_taste_parser(subparsers: argparse._SubParsersAction) -> None: def _add_run_once_parser(subparsers: argparse._SubParsersAction) -> None: run_once_parser = subparsers.add_parser( "run-once", - help="Single refactoring attempt (one agent call, no fix retry).", + help="Run one routed refactoring action without fix retry.", + description="Run one routed refactoring action without fix retry.", ) run_once_parser.set_defaults(handler=_handle_run_once) _add_common_args(run_once_parser) @@ -215,7 +215,8 @@ def _add_run_once_parser(subparsers: argparse._SubParsersAction) -> None: def _add_run_parser(subparsers: argparse._SubParsersAction) -> None: run_parser = subparsers.add_parser( "run", - help="Continuous refactoring loop with fix-prompt retry.", + help="Run routed refactoring actions with fix-prompt retry.", + description="Run routed refactoring actions with fix-prompt retry.", ) run_parser.set_defaults(handler=_handle_run) _add_common_args(run_parser) @@ -229,13 +230,14 @@ def _add_run_parser(subparsers: argparse._SubParsersAction) -> None: "--max-refactors", type=int, default=None, - help="Refactor actions to run.", + help="Actions to run.", ) run_parser.add_argument( "--focus-on-live-migrations", action="store_true", help=( - "Iterate only on live migrations until every one is done or blocked. " + "Iterate only on eligible live migrations until done, deferred, " + "blocked, or failure budget trips. " "Bypasses targeting and --max-refactors requirements." ), ) @@ -258,31 +260,11 @@ def _add_run_parser(subparsers: argparse._SubParsersAction) -> None: ) -def _add_review_parser(subparsers: argparse._SubParsersAction) -> None: - review_parser = subparsers.add_parser( - "review", - help="Review migrations awaiting human review.", - ) - review_parser.set_defaults(handler=handle_review) - review_sub = review_parser.add_subparsers(dest="review_command") - review_sub.add_parser("list", help="List migrations flagged for review.") - perform_parser = review_sub.add_parser( - "perform", - help="Perform review on a flagged migration.", - ) - perform_parser.add_argument("migration", help="Migration name to review.") - perform_parser.add_argument( - "--with", dest="agent", choices=("codex", "claude"), required=True, - help="Agent backend.", - ) - perform_parser.add_argument("--model", required=True, help="Model name.") - perform_parser.add_argument("--effort", required=True, help="Effort level.") - - def _add_migration_parser(subparsers: argparse._SubParsersAction) -> None: migration_parser = subparsers.add_parser( "migration", - help="Inspect live migrations.", + help="Inspect and manage live migrations.", + description="Inspect and manage live migrations.", ) migration_parser.set_defaults(handler=handle_migration) migration_sub = migration_parser.add_subparsers(dest="migration_command") @@ -302,6 +284,11 @@ def _add_migration_parser(subparsers: argparse._SubParsersAction) -> None: action="store_true", help="Only show migrations awaiting human review.", ) + list_parser.add_argument( + "--no-headers", + action="store_true", + help="Omit the TSV header row.", + ) doctor_parser = migration_sub.add_parser( "doctor", @@ -320,7 +307,11 @@ def _add_migration_parser(subparsers: argparse._SubParsersAction) -> None: review_parser = migration_sub.add_parser( "review", - help="Perform staged review on a flagged migration.", + help="Resolve a migration awaiting human review in a staged workspace.", + description=( + "Resolve a migration awaiting human review in a staged workspace. " + "Requires --with and --model." + ), ) review_parser.add_argument("target", help="Migration slug or contained path.") review_parser.add_argument( @@ -328,13 +319,11 @@ def _add_migration_parser(subparsers: argparse._SubParsersAction) -> None: help="Agent backend.", ) review_parser.add_argument("--model", required=True, help="Model name.") - review_parser.add_argument( - "--effort", choices=EFFORT_TIERS, required=True, help="Effort level." - ) refine_parser = migration_sub.add_parser( "refine", - help="Refine a planning migration with user feedback.", + help="Apply feedback to a planning or unexecuted ready migration.", + description="Apply feedback to a planning or unexecuted ready migration.", ) refine_parser.add_argument("target", help="Migration slug or contained path.") feedback_group = refine_parser.add_mutually_exclusive_group(required=True) @@ -349,9 +338,6 @@ def _add_migration_parser(subparsers: argparse._SubParsersAction) -> None: help="Agent backend.", ) refine_parser.add_argument("--model", required=True, help="Model name.") - refine_parser.add_argument( - "--effort", choices=EFFORT_TIERS, required=True, help="Effort level." - ) refine_parser.add_argument( "--show-agent-logs", action="store_true", @@ -380,7 +366,6 @@ def build_parser() -> argparse.ArgumentParser: ) upgrade_parser.set_defaults(handler=_handle_upgrade) _add_migration_parser(subparsers) - _add_review_parser(subparsers) return parser @@ -499,7 +484,9 @@ def _configure_repo_taste( if not current.exists(): ensure_taste_file(destination) return - if current.resolve() == destination.resolve(): + current_resolved = current.resolve() + destination_resolved = destination.resolve() + if current_resolved == destination_resolved: return if not current.is_file(): raise ContinuousRefactorError( @@ -529,15 +516,17 @@ def _configure_live_migrations_dir( if current is None or not current.exists(): destination.mkdir(parents=True, exist_ok=True) return + current_resolved = current.resolve() + destination_resolved = destination.resolve() if not current.is_dir(): raise ContinuousRefactorError( f"Configured live migrations path is not a directory: {current}" ) - if current.resolve() == destination.resolve(): + if current_resolved == destination_resolved: return if ( - destination.resolve().is_relative_to(current.resolve()) - or current.resolve().is_relative_to(destination.resolve()) + destination_resolved.is_relative_to(current_resolved) + or current_resolved.is_relative_to(destination_resolved) ): raise ContinuousRefactorError( "Live migrations directory cannot be moved into itself or one of " @@ -620,9 +609,7 @@ def _active_taste_mode(args: argparse.Namespace) -> str | None: def _taste_agent_flags_set(args: argparse.Namespace) -> bool: - return any( - getattr(args, name, None) is not None for name in ("agent", "model", "effort") - ) + return any(getattr(args, name, None) is not None for name in ("agent", "model")) def _require_taste_action_flags( @@ -630,14 +617,12 @@ def _require_taste_action_flags( action: str, agent: str | None, model: str | None, - effort: str | None, ) -> None: missing = [ flag for flag, value in ( ("--with", agent), ("--model", model), - ("--effort", effort), ) if not value ] @@ -661,7 +646,7 @@ def _run_taste_agent( returncode = run_agent_interactive_until_settled( args.agent, args.model, - args.effort, + _TASTE_AGENT_EFFORT, prompt, Path.cwd().resolve(), content_path=path, @@ -729,7 +714,7 @@ def _handle_taste(args: argparse.Namespace) -> None: if mode is None: if _taste_agent_flags_set(args): print( - "Error: --with/--model/--effort require --interview, --upgrade, or --refine.", + "Error: --with/--model require --interview, --upgrade, or --refine.", file=sys.stderr, ) raise SystemExit(2) @@ -741,7 +726,6 @@ def _handle_taste(args: argparse.Namespace) -> None: action=mode, agent=getattr(args, "agent", None), model=getattr(args, "model", None), - effort=getattr(args, "effort", None), ) return _TASTE_MODE_HANDLERS[mode](args) diff --git a/src/continuous_refactoring/migration_cli.py b/src/continuous_refactoring/migration_cli.py index 5bf0f88..51870b0 100644 --- a/src/continuous_refactoring/migration_cli.py +++ b/src/continuous_refactoring/migration_cli.py @@ -12,6 +12,7 @@ create_run_artifacts, ) from continuous_refactoring.config import resolve_live_migrations_dir, resolve_project +from continuous_refactoring.effort import EffortTier from continuous_refactoring.migration_consistency import ( MigrationConsistencyFinding, check_migration_consistency, @@ -43,7 +44,19 @@ ] _MIGRATION_USAGE = "Usage: continuous-refactoring migration {list,doctor,review,refine}" +_MIGRATION_MANUAL_AGENT_EFFORT: EffortTier = "high" _MISSING_TEXT = "(none)" +_LIST_HEADER = "\t".join( + ( + "slug", + "status", + "cursor", + "awaiting_review", + "last_touch", + "cooldown", + "reason", + ) +) @dataclass(frozen=True) @@ -74,6 +87,8 @@ def handle_migration(args: argparse.Namespace) -> None: def handle_migration_list(args: argparse.Namespace) -> None: context = _resolve_context(error_code=1) + if not bool(getattr(args, "no_headers", False)): + print(_LIST_HEADER) if not context.live_dir.is_dir(): return @@ -122,15 +137,7 @@ def handle_migration_doctor(args: argparse.Namespace) -> None: def handle_migration_review(args: argparse.Namespace) -> None: context = _resolve_context(error_code=2) - try: - target = resolve_migration_target( - live_dir=context.live_dir, - repo_root=context.repo_root, - value=args.target, - ) - except ContinuousRefactorError as error: - print(f"Error: {error}", file=sys.stderr) - raise SystemExit(2) from error + target = _resolve_target_or_exit(context=context, value=args.target, error_code=2) from continuous_refactoring.config import load_taste from continuous_refactoring.review_cli import ( @@ -151,7 +158,7 @@ def handle_migration_review(args: argparse.Namespace) -> None: project_state_dir=context.project_state_dir, agent=args.agent, model=args.model, - effort=args.effort, + effort=_MIGRATION_MANUAL_AGENT_EFFORT, taste=taste, ) ) @@ -160,15 +167,7 @@ def handle_migration_review(args: argparse.Namespace) -> None: def handle_migration_refine(args: argparse.Namespace) -> None: context = _resolve_context(error_code=2) feedback_text, feedback_source = _read_refine_feedback(args) - try: - target = resolve_migration_target( - live_dir=context.live_dir, - repo_root=context.repo_root, - value=args.target, - ) - except ContinuousRefactorError as error: - print(f"Error: {error}", file=sys.stderr) - raise SystemExit(2) from error + target = _resolve_target_or_exit(context=context, value=args.target, error_code=2) from continuous_refactoring.config import load_taste from continuous_refactoring.log_mirroring import LogMirroring @@ -187,7 +186,7 @@ def handle_migration_refine(args: argparse.Namespace) -> None: context.repo_root, agent=args.agent, model=args.model, - effort=args.effort, + effort=_MIGRATION_MANUAL_AGENT_EFFORT, test_command="migration refine", ) try: @@ -202,7 +201,7 @@ def handle_migration_refine(args: argparse.Namespace) -> None: artifacts=artifacts, agent=args.agent, model=args.model, - effort=args.effort, + effort=_MIGRATION_MANUAL_AGENT_EFFORT, log_mirroring=LogMirroring( agent=bool(getattr(args, "show_agent_logs", False)), ), @@ -275,6 +274,23 @@ def _read_refine_feedback(args: argparse.Namespace) -> tuple[str, FeedbackSource return text, source +def _resolve_target_or_exit( + *, + context: MigrationCliContext, + value: str, + error_code: int, +) -> MigrationTarget: + try: + return resolve_migration_target( + live_dir=context.live_dir, + repo_root=context.repo_root, + value=value, + ) + except ContinuousRefactorError as error: + print(f"Error: {error}", file=sys.stderr) + raise SystemExit(error_code) from error + + def _refine_publish_error_message(reason: str, slug: str) -> str: if "stale base snapshot" not in reason: return reason diff --git a/src/continuous_refactoring/planning.py b/src/continuous_refactoring/planning.py index 88b3676..5abfe4c 100644 --- a/src/continuous_refactoring/planning.py +++ b/src/continuous_refactoring/planning.py @@ -74,6 +74,7 @@ _EFFORT_REASON_LINE_RE = re.compile( r"^effort_reason:\s*(.+)$", re.IGNORECASE | re.MULTILINE, ) +_REFINE_REOPEN_STEPS = frozenset(("review", "review-2", "final-review")) @dataclass(frozen=True) @@ -1010,8 +1011,9 @@ def _prepare_refine_state( ) -> tuple[MigrationManifest, PlanningState]: _require_refine_eligible(manifest) state = append_planning_feedback(state, feedback_text, feedback_source) - if manifest.status == "ready": + if manifest.status == "ready" or state.next_step in _REFINE_REOPEN_STEPS: state = reopen_planning_for_revise(state) + if manifest.status == "ready": manifest = _refresh_manifest( manifest, workspace_root / "manifest.json", diff --git a/src/continuous_refactoring/planning_state.py b/src/continuous_refactoring/planning_state.py index 6f8ac5e..1aea233 100644 --- a/src/continuous_refactoring/planning_state.py +++ b/src/continuous_refactoring/planning_state.py @@ -72,6 +72,13 @@ _STEP_OUTCOMES: tuple[str, ...] = cast(tuple[str, ...], get_args(PlanningStepOutcome)) _COMPLETED_OUTCOME = "completed" +_REOPENABLE_REVISE_CURSORS = ( + "review", + "review-2", + "final-review", + "terminal-ready", + "terminal-ready-awaiting-human", +) _TERMINAL_BY_DECISION: dict[str, TerminalPlanningCursor] = { "approve-auto": "terminal-ready", "approve-needs-human": "terminal-ready-awaiting-human", @@ -265,7 +272,7 @@ def reopen_planning_for_revise( now: str | None = None, ) -> PlanningState: replay = _replay_details(state) - if replay.next_step not in ("terminal-ready", "terminal-ready-awaiting-human"): + if replay.next_step not in _REOPENABLE_REVISE_CURSORS: raise ContinuousRefactorError( f"Cannot reopen planning state at {replay.next_step!r} for revise" ) @@ -544,10 +551,10 @@ def _validate_revision_base_step_counts(state: PlanningState) -> None: def _reopen_cursor( cursor: PlanningCursor, ) -> tuple[PlanningCursor, str | None, FinalPlanningDecision | None]: - if cursor not in ("terminal-ready", "terminal-ready-awaiting-human"): + if cursor not in _REOPENABLE_REVISE_CURSORS: raise ContinuousRefactorError( "Planning state revision_base_step_counts must point at a " - f"terminal ready cursor, got {cursor!r}" + f"reopenable review cursor, got {cursor!r}" ) return "revise", None, None diff --git a/src/continuous_refactoring/prompts.py b/src/continuous_refactoring/prompts.py index ec5a9a8..5cd4287 100644 --- a/src/continuous_refactoring/prompts.py +++ b/src/continuous_refactoring/prompts.py @@ -30,7 +30,7 @@ "compose_phase_execution_prompt", "compose_phase_ready_prompt", "compose_planning_prompt", - "compose_review_perform_prompt", + "compose_migration_review_prompt", "compose_scope_selection_prompt", "compose_taste_refine_prompt", "compose_taste_upgrade_prompt", @@ -57,6 +57,18 @@ def _join_sections(*sections: str | None) -> str: return "\n\n".join(section for section in sections if section) +def _taste_section(taste: str) -> str: + return f"## Taste\n{taste}" + + +def _validation_section(validation_command: str) -> str: + return f"## Validation\nRun: `{validation_command}`" + + +def _with_retry_context(sections: list[str], retry_context: str | None) -> list[str]: + return [*sections, *_retry_context_sections(retry_context)] + + def _format_target_files(files: tuple[str, ...]) -> str | None: file_lines: list[str] = [] for file_path in files: @@ -446,16 +458,15 @@ def compose_full_prompt( retry_context: str | None = None, fix_amendment: str | None = None, ) -> str: - sections: list[str] = [ + sections: list[str] = _with_retry_context([ f"Attempt {attempt}", base_prompt, REQUIRED_PREAMBLE, - f"## Refactoring Taste\n{taste}", + "## Refactoring Taste\n" + taste, _format_target_files(target.files), _first_scope(target.scoping, scope_instruction), - f"## Validation\nRun: `{validation_command}`", - *_retry_context_sections(retry_context), - ] + _validation_section(validation_command), + ], retry_context) if fix_amendment: sections.append(fix_amendment) return _join_sections(*sections) @@ -774,7 +785,7 @@ def compose_classifier_prompt(target: Target, taste: str) -> str: f"## Target\n{target.description}", _format_target_files(target.files), _first_scope(target.scoping), - f"## Taste\n{taste}", + _taste_section(taste), ) @@ -789,7 +800,7 @@ def compose_scope_selection_prompt( _format_target_files(target.files), _heading_section("Candidates", _format_scope_candidates(candidates)), _first_scope(target.scoping), - f"## Taste\n{taste}", + _taste_section(taste), ) @@ -806,7 +817,7 @@ def compose_planning_prompt( _format_effort_budget(effort_budget), f"## Migration\n{migration_name}", f"## Context\n{context}" if context else None, - f"## Taste\n{taste}", + _taste_section(taste), ) @@ -817,7 +828,7 @@ def compose_phase_ready_prompt( PHASE_READY_CHECK_PROMPT, f"## Phase\n{_format_phase_summary(phase)}", f"## Manifest\n{_format_manifest_summary(manifest)}", - f"## Taste\n{taste}", + _taste_section(taste), ) @@ -828,20 +839,19 @@ def compose_phase_execution_prompt( validation_command: str, retry_context: str | None = None, ) -> str: - sections: list[str] = [ + sections: list[str] = _with_retry_context([ PHASE_EXECUTION_PROMPT, f"## Phase\n{_format_phase_summary(phase)}", f"## Manifest\n{_format_manifest_summary(manifest)}", - f"## Taste\n{taste}", - f"## Validation\nRun: `{validation_command}`", - *_retry_context_sections(retry_context), - ] + _taste_section(taste), + _validation_section(validation_command), + ], retry_context) return _join_sections(*sections) -REVIEW_PERFORM_PROMPT = """\ -You are conducting a human review of a refactoring migration that was flagged -for human input during planning. +MIGRATION_REVIEW_PROMPT = """\ +You are conducting a human review of a refactoring migration that was marked +as awaiting human review by the driver. Project-specific taste is injected by the caller in the `## Taste` section. @@ -859,8 +869,8 @@ def compose_phase_execution_prompt( what the plan assumes. Note any drift you find — stale assumptions change what is worth asking the user. 3. Present the situation to the user: what the migration does, what phase it is - on, and why it was flagged for review. The manifest's "Human review reason" - field (shown below) is the exact reason the driver flagged this migration — + on, and why it is awaiting human review. The manifest's "Human review reason" + field (shown below) is the exact reason the driver marked this migration — surface it verbatim so the user can see what triggered the hand-off. Include any drift you found so the user sees the current shape, not the shape the plan was written against. @@ -888,8 +898,15 @@ def compose_phase_execution_prompt( - Any plan or phase file updates MUST be written before exiting\ """ +_WORKSPACE_MUTATION_CONTRACT_LINES = ( + "Writable target: staged work dir only.\n" + "Writable target: work dir only.\n" + "The live migration directory is read-only reference material.\n" + "Do not mutate the live migration directory." +) + -def compose_review_perform_prompt( +def compose_migration_review_prompt( migration_name: str, repo_root: Path, work_dir: Path, @@ -900,7 +917,7 @@ def compose_review_perform_prompt( ) -> str: reason = manifest.human_review_reason or "(no reason recorded)" sections: list[str] = [ - REVIEW_PERFORM_PROMPT, + MIGRATION_REVIEW_PROMPT, f"## Migration\nName: {migration_name}", ( "## Workspace\n" @@ -908,10 +925,7 @@ def compose_review_perform_prompt( f"Staged work dir: {work_dir}\n" f"Work dir: {work_dir}\n" f"Live migration dir: {live_dir}\n" - "Writable target: staged work dir only.\n" - "Writable target: work dir only.\n" - "The live migration directory is read-only reference material.\n" - "Do not mutate the live migration directory." + + _WORKSPACE_MUTATION_CONTRACT_LINES ), f"## Human Review\n{reason}", ( @@ -933,5 +947,5 @@ def compose_review_perform_prompt( "Current phase file: (none)\n" "Current phase name: (none)" ) - sections.append(f"## Taste\n{taste}") + sections.append(_taste_section(taste)) return _join_sections(*sections) diff --git a/src/continuous_refactoring/refactor_attempts.py b/src/continuous_refactoring/refactor_attempts.py index fb4e428..8aa116a 100644 --- a/src/continuous_refactoring/refactor_attempts.py +++ b/src/continuous_refactoring/refactor_attempts.py @@ -103,39 +103,6 @@ def _retry_context(record: DecisionRecord) -> str: return "\n".join(lines) -def _decision_record( - *, - decision: str, - retry_recommendation: str, - target: str, - call_role: str, - phase_reached: str, - failure_kind: str, - summary: str, - next_retry_focus: str | None = None, - agent_last_message_path: Path | None = None, - agent_stdout_path: Path | None = None, - agent_stderr_path: Path | None = None, - tests_stdout_path: Path | None = None, - tests_stderr_path: Path | None = None, -) -> DecisionRecord: - return DecisionRecord( - decision=decision, - retry_recommendation=retry_recommendation, - target=target, - call_role=call_role, - phase_reached=phase_reached, - failure_kind=failure_kind, - summary=summary, - next_retry_focus=next_retry_focus, - agent_last_message_path=agent_last_message_path, - agent_stdout_path=agent_stdout_path, - agent_stderr_path=agent_stderr_path, - tests_stdout_path=tests_stdout_path, - tests_stderr_path=tests_stderr_path, - ) - - def _restore_and_retry( *, repo_root: Path, @@ -154,7 +121,7 @@ def _restore_and_retry( tests_stderr_path: Path | None = None, ) -> DecisionRecord: _reset_to_source_baseline(repo_root, head_before, preserved_workspace) - return _decision_record( + return DecisionRecord( decision="retry", retry_recommendation="same-target", target=target, @@ -439,7 +406,7 @@ def _run_refactor_attempt( agent_status.retry_recommendation or default_retry_recommendation(decision) ) - return _decision_record( + return DecisionRecord( decision=decision, retry_recommendation=retry_recommendation, target=target.description, @@ -476,7 +443,7 @@ def _run_refactor_attempt( phase="refactor", ) - return _decision_record( + return DecisionRecord( decision="commit", retry_recommendation="none", target=target.description, diff --git a/src/continuous_refactoring/review_cli.py b/src/continuous_refactoring/review_cli.py index 4c63b07..7ded76f 100644 --- a/src/continuous_refactoring/review_cli.py +++ b/src/continuous_refactoring/review_cli.py @@ -1,6 +1,5 @@ from __future__ import annotations -import argparse import shutil import sys import uuid @@ -9,20 +8,14 @@ from continuous_refactoring.agent import run_agent_interactive from continuous_refactoring.artifacts import ContinuousRefactorError -from continuous_refactoring.config import ( - load_taste, - resolve_live_migrations_dir, - resolve_project, -) -from continuous_refactoring.migration_cli import MigrationTarget, resolve_migration_target +from continuous_refactoring.migration_cli import MigrationTarget from continuous_refactoring.migration_consistency import ( check_migration_consistency, has_blocking_consistency_findings, - iter_visible_migration_dirs, ) from continuous_refactoring.migrations import ( + MigrationManifest, load_manifest as load_migration_manifest, - phase_file_reference, resolve_current_phase, ) from continuous_refactoring.planning_publish import ( @@ -33,26 +26,13 @@ prepare_planning_workspace, publish_planning_workspace, ) -from continuous_refactoring.prompts import compose_review_perform_prompt +from continuous_refactoring.prompts import compose_migration_review_prompt __all__ = [ "StagedReviewRequest", - "handle_review", - "handle_review_list", - "handle_review_perform", "handle_staged_migration_review", - "perform_staged_migration_review", ] -_REVIEW_USAGE = "Usage: continuous-refactoring review {list,perform}" - - -@dataclass(frozen=True) -class _ReviewCliContext: - repo_root: Path - live_dir: Path - project_state_dir: Path - @dataclass(frozen=True) class StagedReviewRequest: @@ -72,95 +52,11 @@ def __init__(self, message: str, exit_code: int) -> None: super().__init__(message) -def _resolve_review_context(*, error_code: int) -> _ReviewCliContext: - try: - project = resolve_project(Path.cwd().resolve()) - except ContinuousRefactorError: - print( - "Error: project not initialized; no live-migrations-dir available.", - file=sys.stderr, - ) - raise SystemExit(error_code) - try: - live_dir = resolve_live_migrations_dir(project) - except ContinuousRefactorError as error: - print(f"Error: {error}", file=sys.stderr) - raise SystemExit(error_code) - if live_dir is None: - print( - "Error: no live-migrations-dir configured for this project.", - file=sys.stderr, - ) - raise SystemExit(error_code) - - return _ReviewCliContext( - repo_root=Path(project.entry.path).resolve(), - live_dir=live_dir, - project_state_dir=project.project_dir, - ) - - -def handle_review_list() -> None: - context = _resolve_review_context(error_code=1) - live_dir = context.live_dir - - if not live_dir.is_dir(): - return - - for child in iter_visible_migration_dirs(live_dir): - manifest_file = child / "manifest.json" - if not manifest_file.exists(): - continue - manifest = load_migration_manifest(manifest_file) - if manifest.awaiting_human_review: - reason = manifest.human_review_reason or "(no reason recorded)" - phase = resolve_current_phase(manifest) if manifest.current_phase else None - phase_file = phase_file_reference(phase) if phase is not None else "(none)" - phase_name = phase.name if phase is not None else "(none)" - print( - f"{manifest.name}\t{manifest.status}\t" - f"{phase_file}\t{phase_name}\t{manifest.last_touch}\t" - f"{reason}" - ) - - -def handle_review_perform(args: argparse.Namespace) -> None: - context = _resolve_review_context(error_code=2) - try: - target = resolve_migration_target( - live_dir=context.live_dir, - repo_root=context.repo_root, - value=args.migration, - ) - except ContinuousRefactorError as error: - print(f"Error: {error}", file=sys.stderr) - raise SystemExit(2) from error - - try: - taste = load_taste(resolve_project(context.repo_root)) - except ContinuousRefactorError as error: - print(f"Error: {error}", file=sys.stderr) - raise SystemExit(1) from error - - handle_staged_migration_review( - StagedReviewRequest( - repo_root=context.repo_root, - live_dir=context.live_dir, - target=target, - project_state_dir=context.project_state_dir, - agent=args.agent, - model=args.model, - effort=args.effort, - taste=taste, - ) - ) - - def handle_staged_migration_review( request: StagedReviewRequest, ) -> PlanningPublishResult: try: - return perform_staged_migration_review(request) + return _run_staged_migration_review(request) except _ReviewCliError as error: print(f"Error: {error}", file=sys.stderr) raise SystemExit(error.exit_code) from error @@ -175,7 +71,7 @@ def handle_staged_migration_review( raise SystemExit(1) from error -def perform_staged_migration_review( +def _run_staged_migration_review( request: StagedReviewRequest, ) -> PlanningPublishResult: manifest_path = request.target.path / "manifest.json" @@ -188,7 +84,7 @@ def perform_staged_migration_review( manifest = load_migration_manifest(manifest_path) if not manifest.awaiting_human_review: raise _ReviewCliError( - f"migration '{request.target.slug}' is not flagged for review.", + _not_awaiting_review_message(request.target.slug, manifest), 2, ) @@ -210,7 +106,7 @@ def perform_staged_migration_review( ) from error phase = resolve_current_phase(manifest) if manifest.current_phase else None - prompt = compose_review_perform_prompt( + prompt = compose_migration_review_prompt( request.target.slug, request.repo_root, workspace.root, @@ -275,6 +171,35 @@ def _require_consistent_review_workspace(workspace_root: Path) -> None: ) +def _not_awaiting_review_message(slug: str, manifest: MigrationManifest) -> str: + message = ( + f"migration '{slug}' is not awaiting human review. " + "Reviewable migrations are listed by " + "`continuous-refactoring migration list --awaiting-review`." + ) + if _is_refine_eligible(manifest): + return ( + f"{message} To revise this migration instead, run " + f"`continuous-refactoring migration refine {slug} ...`." + ) + return ( + f"{message} `continuous-refactoring migration refine {slug} ...` " + "is only available for planning or unexecuted ready migrations." + ) + + +def _is_refine_eligible(manifest: MigrationManifest) -> bool: + if any(phase.done for phase in manifest.phases): + return False + if manifest.status == "planning": + return True + if manifest.status != "ready": + return False + if not manifest.phases: + return False + return manifest.current_phase == manifest.phases[0].name + + def _review_publish_error_message(error: PlanningPublishError, slug: str) -> str: message = str(error) if "stale base snapshot" not in error.result.reason: @@ -285,12 +210,3 @@ def _review_publish_error_message(error: PlanningPublishError, slug: str) -> str f"Run `continuous-refactoring migration doctor {slug}` if unsure, then " f"rerun `continuous-refactoring migration review {slug} ...`." ) - - -def handle_review(args: argparse.Namespace) -> None: - if args.review_command == "list": - return handle_review_list() - if args.review_command == "perform": - return handle_review_perform(args) - print(_REVIEW_USAGE, file=sys.stderr) - raise SystemExit(2) diff --git a/src/continuous_refactoring/scope_candidates.py b/src/continuous_refactoring/scope_candidates.py index b766c7b..dca2552 100644 --- a/src/continuous_refactoring/scope_candidates.py +++ b/src/continuous_refactoring/scope_candidates.py @@ -259,6 +259,15 @@ def _candidate_from_files( ) +def _append_candidate_if_unique( + candidates: list[ScopeCandidate], + candidate: ScopeCandidate, +) -> None: + if any(existing.files == candidate.files for existing in candidates): + return + candidates.append(candidate) + + def _record_support( support_lines: dict[str, list[str]], support_kinds: dict[str, list[_SupportKind]], @@ -406,10 +415,11 @@ def include_cross(same_dir: bool, support_kinds: tuple[_SupportKind, ...]) -> bo local_extras = tuple(local_ranked[: max_files - 1]) if local_extras: local_files = (seed_file, *local_extras) - candidates.append( + _append_candidate_if_unique( + candidates, _candidate_from_files( "local-cluster", seed_file, local_files, support.evidence, - ) + ), ) cross_ranked = _rank_paths( @@ -421,10 +431,11 @@ def include_cross(same_dir: bool, support_kinds: tuple[_SupportKind, ...]) -> bo cross_extras = tuple(cross_ranked[: max_files - 1]) if cross_extras: cross_files = (seed_file, *cross_extras) - cross_candidate = _candidate_from_files( - "cross-cluster", seed_file, cross_files, support.evidence, + _append_candidate_if_unique( + candidates, + _candidate_from_files( + "cross-cluster", seed_file, cross_files, support.evidence, + ), ) - if cross_candidate.files != candidates[-1].files: - candidates.append(cross_candidate) return tuple(candidates[:max_candidates]) diff --git a/tests/conftest.py b/tests/conftest.py index 891004f..9023866 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -122,6 +122,7 @@ def fake( assert content_path == extract_taste_path(prompt) assert settle_path == extract_settle_path(prompt) if captured is not None: + captured["effort"] = _effort captured["prompt"] = prompt captured["content_path"] = str(content_path) captured["settle_path"] = str(settle_path) @@ -143,7 +144,6 @@ def make_taste_args( global_: bool = False, agent: str | None = None, model: str | None = None, - effort: str | None = None, force: bool = False, ) -> argparse.Namespace: return argparse.Namespace( @@ -153,7 +153,6 @@ def make_taste_args( refine=mode == "refine", agent=agent, model=model, - effort=effort, force=force, ) diff --git a/tests/test_cli_migrations.py b/tests/test_cli_migrations.py index 3702a7f..4e01ac3 100644 --- a/tests/test_cli_migrations.py +++ b/tests/test_cli_migrations.py @@ -39,6 +39,7 @@ ) _CREATED = "2025-01-01T00:00:00+00:00" +_LIST_HEADER = "slug\tstatus\tcursor\tawaiting_review\tlast_touch\tcooldown\treason" _PHASE = PhaseSpec( name="setup", file="phase-1-setup.md", @@ -54,12 +55,21 @@ def test_migration_parser_accepts_list_and_doctor() -> None: assert list_args.command == "migration" assert list_args.migration_command == "list" assert list_args.handler.__name__ == "handle_migration" + assert list_args.no_headers is False filtered = parser.parse_args( - ["migration", "list", "--status", "planning", "--awaiting-review"] + [ + "migration", + "list", + "--status", + "planning", + "--awaiting-review", + "--no-headers", + ] ) assert filtered.status == "planning" assert filtered.awaiting_review is True + assert filtered.no_headers is True doctor_args = parser.parse_args(["migration", "doctor", "my-mig"]) assert doctor_args.migration_command == "doctor" @@ -75,15 +85,81 @@ def test_migration_parser_accepts_list_and_doctor() -> None: "codex", "--model", "test-model", - "--effort", - "low", ] ) assert review_args.migration_command == "review" assert review_args.target == "my-mig" assert review_args.agent == "codex" assert review_args.model == "test-model" - assert review_args.effort == "low" + assert not hasattr(review_args, "effort") + + +def test_top_level_review_command_is_not_registered() -> None: + parser = build_parser() + + with pytest.raises(SystemExit) as exit_info: + parser.parse_args(["review"]) + + assert exit_info.value.code == 2 + + +def test_run_help_describes_routed_actions( + capsys: pytest.CaptureFixture[str], +) -> None: + parser = build_parser() + + top_help = _help_text(parser, ["--help"], capsys) + run_once_help = _help_text(parser, ["run-once", "--help"], capsys) + run_help = _help_text(parser, ["run", "--help"], capsys) + + assert "Run one routed refactoring action without fix retry." in top_help + assert "Run one routed refactoring action without fix retry." in run_once_help + assert "Run routed refactoring actions with fix-prompt retry." in top_help + assert "Run routed refactoring actions with fix-prompt retry." in run_help + assert "Actions to run." in run_help + assert "eligible live migrations until done" in run_help + assert "deferred" in run_help + assert "blocked" in run_help + assert "one agent call" not in top_help + assert "Refactor actions to run." not in run_help + + +def test_migration_help_describes_review_and_refine( + capsys: pytest.CaptureFixture[str], +) -> None: + parser = build_parser() + + top_help = _help_text(parser, ["--help"], capsys) + migration_help = _help_text(parser, ["migration", "--help"], capsys) + review_help = _help_text(parser, ["migration", "review", "--help"], capsys) + refine_help = _help_text(parser, ["migration", "refine", "--help"], capsys) + + assert "Inspect and manage live migrations." in top_help + assert "Inspect and manage live migrations." in migration_help + assert "Resolve a migration awaiting human review" in migration_help + assert "staged workspace" in migration_help + assert "Resolve a migration awaiting human review" in review_help + assert "staged workspace" in review_help + assert "Requires --with and --model." in review_help + assert "Apply feedback to a planning or unexecuted ready" in migration_help + assert "Apply feedback to a planning or unexecuted ready" in refine_help + assert "--effort" not in review_help + assert "--effort" not in refine_help + assert "Compatibility shortcut" not in top_help + assert "Perform staged " + "review" not in migration_help + assert "flagged " + "migration" not in migration_help + + +def test_taste_help_describes_scope_and_agent_backed_modes( + capsys: pytest.CaptureFixture[str], +) -> None: + parser = build_parser() + + taste_help = _help_text(parser, ["taste", "--help"], capsys) + + assert "Manage project or global taste files." in taste_help + assert "Agent-backed modes require --with and --model." in taste_help + assert "--effort" not in taste_help def test_migration_parser_accepts_doctor_all() -> None: @@ -106,19 +182,20 @@ def test_documented_migration_commands_match_parser() -> None: "continuous-refactoring migration list", "continuous-refactoring migration list --status planning", "continuous-refactoring migration list --awaiting-review", + "continuous-refactoring migration list --no-headers", "continuous-refactoring migration doctor ", "continuous-refactoring migration doctor --all", ( "continuous-refactoring migration review --with codex " - "--model gpt-5 --effort high" + "--model gpt-5" ), ( "continuous-refactoring migration refine --message " - "\"split the risky phase\" --with codex --model gpt-5 --effort high" + "\"split the risky phase\" --with codex --model gpt-5" ), ( "continuous-refactoring migration refine --file " - "feedback.md --with codex --model gpt-5 --effort high" + "feedback.md --with codex --model gpt-5" ), ) @@ -142,6 +219,17 @@ def _canonical_migration_commands(readme: str) -> tuple[str, ...]: ) +def _help_text( + parser: argparse.ArgumentParser, + argv: list[str], + capsys: pytest.CaptureFixture[str], +) -> str: + with pytest.raises(SystemExit) as exit_info: + parser.parse_args(argv) + assert exit_info.value.code == 0 + return " ".join(capsys.readouterr().out.split()) + + def _argv_from_documented_command(command: str) -> list[str]: parts = shlex.split(command) if parts[0] != "continuous-refactoring": @@ -165,8 +253,6 @@ def test_migration_refine_requires_message_or_file() -> None: "codex", "--model", "test-model", - "--effort", - "low", ] ) assert missing_exit.value.code == 2 @@ -185,8 +271,6 @@ def test_migration_refine_requires_message_or_file() -> None: "codex", "--model", "test-model", - "--effort", - "low", ] ) assert both_exit.value.code == 2 @@ -202,8 +286,6 @@ def test_migration_refine_requires_message_or_file() -> None: "codex", "--model", "test-model", - "--effort", - "low", "--show-agent-logs", ] ) @@ -214,7 +296,7 @@ def test_migration_refine_requires_message_or_file() -> None: assert args.file is None assert args.agent == "codex" assert args.model == "test-model" - assert args.effort == "low" + assert not hasattr(args, "effort") assert args.show_agent_logs is True with pytest.raises(SystemExit) as command_logs_exit: @@ -229,14 +311,50 @@ def test_migration_refine_requires_message_or_file() -> None: "codex", "--model", "test-model", - "--effort", - "low", "--show-command-logs", ] ) assert command_logs_exit.value.code == 2 +@pytest.mark.parametrize( + "argv", + [ + [ + "migration", + "review", + "my-mig", + "--with", + "codex", + "--model", + "test-model", + "--effort", + "high", + ], + [ + "migration", + "refine", + "my-mig", + "--message", + "tighten it", + "--with", + "codex", + "--model", + "test-model", + "--effort", + "high", + ], + ], +) +def test_migration_review_and_refine_reject_effort_flag(argv: list[str]) -> None: + parser = build_parser() + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args(argv) + + assert exc_info.value.code == 2 + + def test_migration_list_includes_planning_ready_review_and_done_statuses( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -259,6 +377,15 @@ def test_migration_list_includes_planning_ready_review_and_done_statuses( lines = [line.split("\t") for line in capsys.readouterr().out.splitlines()] assert lines == [ + [ + "slug", + "status", + "cursor", + "awaiting_review", + "last_touch", + "cooldown", + "reason", + ], [ "done-mig", "done", @@ -304,11 +431,44 @@ def test_migration_list_filters_by_status_and_awaiting_review( handle_migration_list(_list_args(status="ready", awaiting_review=True)) assert capsys.readouterr().out.splitlines() == [ + _LIST_HEADER, "ready-review\tready\tphase-1-setup.md\tyes\t" f"{_CREATED}\t(none)\t(none)" ] +def test_migration_list_no_headers_preserves_parseable_rows( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _repo, live_dir = _init_migration_project(tmp_path, monkeypatch) + _write_migration(live_dir, "ready-normal") + + handle_migration_list(_list_args(no_headers=True)) + + assert capsys.readouterr().out.splitlines() == [ + "ready-normal\tready\tphase-1-setup.md\tno\t" + f"{_CREATED}\t(none)\t(none)" + ] + + +def test_migration_list_headers_for_empty_results( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _repo, _live_dir = _init_migration_project(tmp_path, monkeypatch) + + handle_migration_list(_list_args()) + + assert capsys.readouterr().out == f"{_LIST_HEADER}\n" + + handle_migration_list(_list_args(no_headers=True)) + + assert capsys.readouterr().out == "" + + def test_migration_list_marks_invalid_planning_state_as_blocked( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -322,7 +482,7 @@ def test_migration_list_marks_invalid_planning_state_as_blocked( state_path.parent.mkdir(parents=True) state_path.write_text("{not json\n", encoding="utf-8") - handle_migration_list(_list_args()) + handle_migration_list(_list_args(no_headers=True)) fields = capsys.readouterr().out.strip().split("\t") assert fields[0:3] == ["planning-mig", "planning", "planning:blocked"] @@ -345,7 +505,7 @@ def fail_resolve(_manifest: MigrationManifest) -> PhaseSpec: fail_resolve, ) - handle_migration_list(_list_args()) + handle_migration_list(_list_args(no_headers=True)) fields = capsys.readouterr().out.strip().split("\t") assert fields[0:3] == ["ready-mig", "ready", "blocked"] @@ -440,10 +600,10 @@ def test_migration_review_accepts_slug_or_path_inside_live_root( "target", awaiting_human_review=True, ) - seen: list[Path] = [] + seen: list[tuple[Path, str]] = [] def fake_review(request: object) -> None: - seen.append(request.target.path) + seen.append((request.target.path, request.effort)) monkeypatch.setattr( "continuous_refactoring.review_cli.handle_staged_migration_review", @@ -453,7 +613,7 @@ def fake_review(request: object) -> None: handle_migration_review(_review_args("target")) handle_migration_review(_review_args("migrations/target")) - assert seen == [migration_dir, migration_dir] + assert seen == [(migration_dir, "high"), (migration_dir, "high")] def test_migration_review_rejects_outside_path_and_symlink_escape( @@ -484,25 +644,62 @@ def test_migration_review_rejects_outside_path_and_symlink_escape( assert "symlink" in capsys.readouterr().err -def test_migration_review_rejects_missing_or_not_flagged_migration( +def test_migration_review_rejects_missing_migration_without_refine_suggestion( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: _repo, live_dir = _init_migration_project(tmp_path, monkeypatch) - _write_migration(live_dir, "not-flagged") + _write_migration(live_dir, "not-awaiting-review") with pytest.raises(SystemExit) as missing_exit: handle_migration_review(_review_args("missing")) assert missing_exit.value.code == 2 - assert "does not exist" in capsys.readouterr().err + err = capsys.readouterr().err + assert "does not exist" in err + assert "migration refine" not in err + + +def test_migration_review_rejects_refine_eligible_not_awaiting_review_with_refine_hint( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _repo, live_dir = _init_migration_project(tmp_path, monkeypatch) + _write_migration(live_dir, "not-awaiting-review") - with pytest.raises(SystemExit) as not_flagged_exit: - handle_migration_review(_review_args("not-flagged")) + with pytest.raises(SystemExit) as not_awaiting_review_exit: + handle_migration_review(_review_args("not-awaiting-review")) - assert not_flagged_exit.value.code == 2 - assert "not flagged" in capsys.readouterr().err + assert not_awaiting_review_exit.value.code == 2 + err = capsys.readouterr().err + assert "not awaiting human review" in err + assert "continuous-refactoring migration list --awaiting-review" in err + assert "continuous-refactoring migration refine not-awaiting-review" in err + + +def test_migration_review_rejects_non_refine_eligible_not_awaiting_review_with_guarded_refine_hint( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _repo, live_dir = _init_migration_project(tmp_path, monkeypatch) + _write_migration( + live_dir, + "phase-done", + phases=(replace(_PHASE, done=True),), + ) + + with pytest.raises(SystemExit) as not_awaiting_review_exit: + handle_migration_review(_review_args("phase-done")) + + assert not_awaiting_review_exit.value.code == 2 + err = capsys.readouterr().err + assert "not awaiting human review" in err + assert "continuous-refactoring migration list --awaiting-review" in err + assert "continuous-refactoring migration refine phase-done" in err + assert "only available for planning or unexecuted ready migrations" in err def test_migration_review_runs_agent_against_work_dir( @@ -523,6 +720,7 @@ def fake_interactive( agent: str, model: str, effort: str, prompt: str, repo_root: Path, ) -> int: seen["agent"] = agent + seen["effort"] = effort seen["cwd"] = repo_root seen["prompt"] = prompt manifest = load_manifest(repo_root / "manifest.json") @@ -544,6 +742,7 @@ def fake_interactive( handle_migration_review(_review_args("target")) assert seen["agent"] == "codex" + assert seen["effort"] == "high" assert seen["cwd"] != migration_dir assert isinstance(seen["cwd"], Path) assert seen["cwd"].name == "target" @@ -555,6 +754,50 @@ def fake_interactive( assert reloaded.human_review_reason is None +def test_migration_review_prompt_handles_missing_current_phase( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo, live_dir = _init_migration_project(tmp_path, monkeypatch) + migration_dir = _write_migration( + live_dir, + "target", + awaiting_human_review=True, + current_phase="", + human_review_reason="phase cursor cleared", + ) + _commit_all(repo) + seen: dict[str, str] = {} + + def fake_interactive( + agent: str, model: str, effort: str, prompt: str, repo_root: Path, + ) -> int: + seen["prompt"] = prompt + manifest = load_manifest(repo_root / "manifest.json") + save_manifest( + replace( + manifest, + awaiting_human_review=False, + current_phase="setup", + human_review_reason=None, + ), + repo_root / "manifest.json", + ) + return 0 + + monkeypatch.setattr( + "continuous_refactoring.review_cli.run_agent_interactive", + fake_interactive, + ) + + handle_migration_review(_review_args("target")) + + assert "phase cursor cleared" in seen["prompt"] + assert "Current phase file: (none)" in seen["prompt"] + assert "Current phase name: (none)" in seen["prompt"] + assert load_manifest(migration_dir / "manifest.json").current_phase == "setup" + + def test_migration_review_failure_leaves_live_snapshot_unchanged( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -747,6 +990,9 @@ def test_migration_refine_resumes_from_current_planning_state( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + artifact_tmp = tmp_path / "artifact-tmp" + artifact_tmp.mkdir() + monkeypatch.setenv("TMPDIR", str(artifact_tmp)) repo, live_dir = _init_migration_project(tmp_path, monkeypatch) migration_dir = _write_migration( live_dir, "target", status="planning", current_phase="", phases=(), @@ -783,10 +1029,18 @@ def test_migration_refine_resumes_from_current_planning_state( state = load_planning_state(repo, planning_state_path(migration_dir)) assert fake.stage_labels == ["expand"] + assert fake.efforts == ["high"] assert fake.mirror_to_terminal == [True] assert state.next_step == "review" + assert state.completed_steps[-1].effort == "high" assert state.feedback[-1].source == "message" assert state.feedback[-1].text == "split phase one" + summaries = list((artifact_tmp / "continuous-refactoring").glob("*/summary.json")) + assert len(summaries) == 1 + summary = json.loads(summaries[0].read_text(encoding="utf-8")) + assert summary["effort"] == "high" + assert summary["default_effort"] == "high" + assert summary["max_allowed_effort"] == "high" assert (migration_dir / "plan.md").read_text(encoding="utf-8") == "# Refined Plan\n" @@ -1375,8 +1629,13 @@ def _list_args( *, status: str | None = None, awaiting_review: bool = False, + no_headers: bool = False, ) -> argparse.Namespace: - return argparse.Namespace(status=status, awaiting_review=awaiting_review) + return argparse.Namespace( + status=status, + awaiting_review=awaiting_review, + no_headers=no_headers, + ) def _doctor_args( @@ -1392,7 +1651,6 @@ def _review_args(target: str) -> argparse.Namespace: target=target, agent="codex", model="test-model", - effort="low", ) @@ -1409,7 +1667,6 @@ def _refine_args( file=file, agent="codex", model="test-model", - effort="low", show_agent_logs=show_agent_logs, ) @@ -1442,6 +1699,7 @@ def __init__( self._index = 0 self._on_call = on_call self.stage_labels: list[str] = [] + self.efforts: list[str] = [] self.prompts: list[str] = [] self.mirror_to_terminal: list[bool] = [] @@ -1456,6 +1714,7 @@ def __call__(self, **kwargs: object) -> CommandCapture: self.prompts.append(prompt) self.stage_labels.append(stdout_path.parent.name) + self.efforts.append(str(kwargs["effort"])) self.mirror_to_terminal.append(bool(kwargs["mirror_to_terminal"])) for rel_path, content in writes.items(): path = migration_dir / rel_path diff --git a/tests/test_cli_review.py b/tests/test_cli_review.py deleted file mode 100644 index 78af49d..0000000 --- a/tests/test_cli_review.py +++ /dev/null @@ -1,642 +0,0 @@ -from __future__ import annotations - -import argparse -import subprocess -from pathlib import Path - -import pytest - -from continuous_refactoring.cli import build_parser -from continuous_refactoring.config import register_project, set_live_migrations_dir -from continuous_refactoring.migrations import ( - MigrationManifest, - PhaseSpec, - load_manifest as load_migration_manifest, - save_manifest as save_migration, -) -from continuous_refactoring.review_cli import ( - handle_review, - handle_review_list, - handle_review_perform, -) - -_PHASES = ( - PhaseSpec(name="setup", file="phase-1-setup.md", done=True, precondition="always"), - PhaseSpec( - name="review-target", - file="phase-2-review-target.md", - done=False, - precondition="setup complete", - ), -) - - -def test_review_parser_accepts_list_and_perform_subcommands() -> None: - parser = build_parser() - - review_args = parser.parse_args(["review"]) - assert review_args.command == "review" - assert review_args.review_command is None - - list_args = parser.parse_args(["review", "list"]) - assert list_args.command == "review" - assert list_args.review_command == "list" - - perform_args = parser.parse_args( - [ - "review", - "perform", - "my-mig", - "--with", - "codex", - "--model", - "test-model", - "--effort", - "low", - ], - ) - assert perform_args.command == "review" - assert perform_args.review_command == "perform" - assert perform_args.migration == "my-mig" - assert perform_args.agent == "codex" - assert perform_args.model == "test-model" - assert perform_args.effort == "low" - - -def test_parser_binds_handlers_for_top_level_commands() -> None: - parser = build_parser() - - assert parser.parse_args(["init"]).handler.__name__ == "_handle_init" - assert parser.parse_args(["taste"]).handler.__name__ == "_handle_taste" - assert parser.parse_args(["upgrade"]).handler.__name__ == "_handle_upgrade" - assert parser.parse_args(["review"]).handler.__name__ == "handle_review" - assert parser.parse_args( - ["run-once", "--with", "codex", "--model", "m", "--scope-instruction", "s"] - ).handler.__name__ == "_handle_run_once" - assert parser.parse_args( - [ - "run", - "--with", - "codex", - "--model", - "m", - "--scope-instruction", - "s", - "--max-refactors", - "1", - ] - ).handler.__name__ == "_handle_run" - - -def _init_repo(path: Path) -> None: - path.mkdir(parents=True, exist_ok=True) - subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True) - subprocess.run( - ["git", "config", "user.email", "test@example.com"], - cwd=path, check=True, capture_output=True, - ) - subprocess.run( - ["git", "config", "user.name", "Test User"], - cwd=path, check=True, capture_output=True, - ) - (path / "README.md").write_text("seed\n", encoding="utf-8") - subprocess.run( - ["git", "add", "README.md"], cwd=path, check=True, capture_output=True, - ) - subprocess.run( - ["git", "commit", "-m", "init"], cwd=path, check=True, capture_output=True, - ) - - -def _make_perform_args(migration: str) -> argparse.Namespace: - return argparse.Namespace( - migration=migration, - agent="codex", - model="test-model", - effort="low", - ) - - -def _commit_all(repo: Path, message: str = "test state") -> None: - subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True) - subprocess.run(["git", "commit", "-m", message], cwd=repo, check=True, capture_output=True) - - -def _init_review_project( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> tuple[Path, Path]: - monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) - repo = tmp_path / "project" - _init_repo(repo) - monkeypatch.chdir(repo) - - project = register_project(repo) - live_dir = repo / ".migrations" - live_dir.mkdir() - set_live_migrations_dir(project.entry.uuid, ".migrations") - return repo, live_dir - - -def _init_unconfigured_review_repo( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -) -> Path: - monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) - repo = tmp_path / "project" - _init_repo(repo) - monkeypatch.chdir(repo) - return repo - - -def _make_manifest( - name: str, - *, - awaiting_human_review: bool = False, - status: str = "ready", - current_phase: str = "review-target", - last_touch: str = "2025-01-01T00:00:00+00:00", - human_review_reason: str | None = None, -) -> MigrationManifest: - return MigrationManifest( - name=name, - created_at="2025-01-01T00:00:00+00:00", - last_touch=last_touch, - wake_up_on=None, - awaiting_human_review=awaiting_human_review, - status=status, - current_phase=current_phase, - phases=_PHASES, - human_review_reason=human_review_reason, - ) - - -def test_review_list_filters_flagged_migrations( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - _, live_dir = _init_review_project(tmp_path, monkeypatch) - - save_migration( - _make_manifest( - "listed-a", - awaiting_human_review=True, - status="in-progress", - current_phase="setup", - last_touch="2025-03-02T14:00:00+00:00", - ), - live_dir / "mig-b" / "manifest.json", - ) - save_migration( - _make_manifest( - "listed-without-phase", - awaiting_human_review=True, - status="ready", - current_phase="", - last_touch="2025-03-03T16:00:00+00:00", - human_review_reason="phase cursor cleared", - ), - live_dir / "mig-no-phase" / "manifest.json", - ) - save_migration( - _make_manifest("mig-c", awaiting_human_review=False, status="done"), - live_dir / "mig-c" / "manifest.json", - ) - save_migration( - _make_manifest( - "listed-z", - awaiting_human_review=True, - status="ready", - current_phase="review-target", - last_touch="2025-03-01T12:00:00+00:00", - human_review_reason="needs security audit", - ), - live_dir / "mig-a" / "manifest.json", - ) - - handle_review_list() - - out = capsys.readouterr().out - lines = [line for line in out.strip().splitlines() if line] - assert len(lines) == 3 - - fields_a = lines[0].split("\t") - assert fields_a == [ - "listed-z", - "ready", - "phase-2-review-target.md", - "review-target", - "2025-03-01T12:00:00+00:00", - "needs security audit", - ] - - fields_b = lines[1].split("\t") - assert fields_b == [ - "listed-a", - "in-progress", - "phase-1-setup.md", - "setup", - "2025-03-02T14:00:00+00:00", - "(no reason recorded)", - ] - - fields_no_phase = lines[2].split("\t") - assert fields_no_phase == [ - "listed-without-phase", - "ready", - "(none)", - "(none)", - "2025-03-03T16:00:00+00:00", - "phase cursor cleared", - ] - - -def test_review_list_ignores_hidden_and_transaction_dirs( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - _, live_dir = _init_review_project(tmp_path, monkeypatch) - save_migration( - _make_manifest( - "visible-review", - awaiting_human_review=True, - human_review_reason="visible", - ), - live_dir / "visible-review" / "manifest.json", - ) - save_migration( - _make_manifest( - "hidden-review", - awaiting_human_review=True, - human_review_reason="hidden", - ), - live_dir / ".hidden-review" / "manifest.json", - ) - save_migration( - _make_manifest( - "transaction-review", - awaiting_human_review=True, - human_review_reason="transaction", - ), - live_dir / "__transactions__" / "manifest.json", - ) - - handle_review_list() - - out = capsys.readouterr().out - assert "visible-review\tready" in out - assert "hidden-review" not in out - assert "transaction-review" not in out - - -@pytest.mark.parametrize( - ("handler", "error_code", "setup", "expected_message"), - [ - ( - handle_review_list, - 1, - lambda tmp_path, monkeypatch: _init_unconfigured_review_repo( - tmp_path, monkeypatch, - ), - "project not initialized", - ), - ( - lambda: handle_review_perform(_make_perform_args("my-mig")), - 2, - lambda tmp_path, monkeypatch: _init_unconfigured_review_repo( - tmp_path, monkeypatch, - ), - "project not initialized", - ), - ( - handle_review_list, - 1, - lambda tmp_path, monkeypatch: register_project( - _init_unconfigured_review_repo(tmp_path, monkeypatch), - ), - "live-migrations-dir", - ), - ( - lambda: handle_review_perform(_make_perform_args("my-mig")), - 2, - lambda tmp_path, monkeypatch: register_project( - _init_unconfigured_review_repo(tmp_path, monkeypatch), - ), - "live-migrations-dir", - ), - ( - handle_review_list, - 1, - lambda tmp_path, monkeypatch: _configure_escaped_live_dir( - _init_unconfigured_review_repo(tmp_path, monkeypatch), - ), - "escapes repo", - ), - ( - lambda: handle_review_perform(_make_perform_args("my-mig")), - 2, - lambda tmp_path, monkeypatch: _configure_escaped_live_dir( - _init_unconfigured_review_repo(tmp_path, monkeypatch), - ), - "escapes repo", - ), - ], -) -def test_review_commands_surface_shared_context_errors( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - handler: object, - error_code: int, - setup: object, - expected_message: str, -) -> None: - setup(tmp_path, monkeypatch) - - with pytest.raises(SystemExit) as exc_info: - handler() - - assert exc_info.value.code == error_code - err = capsys.readouterr().err - assert expected_message in err - - -def _configure_escaped_live_dir(repo: Path) -> None: - project = register_project(repo) - set_live_migrations_dir(project.entry.uuid, "../elsewhere") - - -def _setup_review_project( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - *, - awaiting: bool = True, - current_phase: str = "review-target", - human_review_reason: str | None = None, -) -> tuple[Path, Path]: - repo, live_dir = _init_review_project(tmp_path, monkeypatch) - save_migration( - _make_manifest( - "my-mig", - awaiting_human_review=awaiting, - status="ready", - current_phase=current_phase, - human_review_reason=human_review_reason, - ), - live_dir / "my-mig" / "manifest.json", - ) - (live_dir / "my-mig" / "plan.md").write_text("# Plan\n", encoding="utf-8") - for phase in _PHASES: - (live_dir / "my-mig" / phase.file).write_text( - "# Phase\n\n" - "## Precondition\n\n" - "Ready.\n\n" - "## Definition of Done\n\n" - "Done.\n", - encoding="utf-8", - ) - return repo, live_dir - - -def test_review_perform_happy_path( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo, live_dir = _setup_review_project( - tmp_path, monkeypatch, - awaiting=True, - human_review_reason="needs security audit", - ) - _commit_all(repo) - manifest_path = live_dir / "my-mig" / "manifest.json" - captured_prompt: dict[str, str] = {} - - def fake_interactive( - agent: str, model: str, effort: str, prompt: str, repo_root: Path, - ) -> int: - captured_prompt["prompt"] = prompt - captured_prompt["repo_root"] = str(repo_root) - manifest = load_migration_manifest(repo_root / "manifest.json") - from dataclasses import replace - updated = replace( - manifest, - awaiting_human_review=False, - human_review_reason=None, - ) - save_migration(updated, repo_root / "manifest.json") - return 0 - - monkeypatch.setattr( - "continuous_refactoring.review_cli.run_agent_interactive", fake_interactive, - ) - - handle_review_perform(_make_perform_args("my-mig")) - - assert "needs security audit" in captured_prompt["prompt"] - assert "phase-2-review-target.md" in captured_prompt["prompt"] - assert "Name: review-target" in captured_prompt["prompt"] - assert captured_prompt["repo_root"] != str(Path.cwd().resolve()) - assert captured_prompt["repo_root"].endswith("/work/my-mig") - - reloaded = load_migration_manifest(manifest_path) - assert reloaded.awaiting_human_review is False - assert reloaded.human_review_reason is None - - -def test_review_perform_happy_path_without_current_phase( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo, live_dir = _setup_review_project( - tmp_path, monkeypatch, - awaiting=True, - current_phase="", - human_review_reason="phase cursor cleared", - ) - _commit_all(repo) - manifest_path = live_dir / "my-mig" / "manifest.json" - captured_prompt: dict[str, str] = {} - - def fake_interactive( - agent: str, model: str, effort: str, prompt: str, repo_root: Path, - ) -> int: - captured_prompt["prompt"] = prompt - manifest = load_migration_manifest(repo_root / "manifest.json") - from dataclasses import replace - updated = replace( - manifest, - awaiting_human_review=False, - current_phase="review-target", - human_review_reason=None, - ) - save_migration(updated, repo_root / "manifest.json") - return 0 - - monkeypatch.setattr( - "continuous_refactoring.review_cli.run_agent_interactive", fake_interactive, - ) - - handle_review_perform(_make_perform_args("my-mig")) - - assert "phase cursor cleared" in captured_prompt["prompt"] - assert "Current phase file: (none)" in captured_prompt["prompt"] - assert "Current phase name: (none)" in captured_prompt["prompt"] - - -def test_review_perform_exits_1_when_flag_not_cleared( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - repo, live_dir = _setup_review_project(tmp_path, monkeypatch, awaiting=True) - _commit_all(repo) - - def fake_interactive( - agent: str, model: str, effort: str, prompt: str, repo_root: Path, - ) -> int: - return 0 - - monkeypatch.setattr( - "continuous_refactoring.review_cli.run_agent_interactive", fake_interactive, - ) - - with pytest.raises(SystemExit) as exc_info: - handle_review_perform(_make_perform_args("my-mig")) - - assert exc_info.value.code == 1 - err = capsys.readouterr().err - assert "not completed" in err - - -def test_review_perform_exits_with_agent_returncode( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - repo, _live_dir = _setup_review_project(tmp_path, monkeypatch, awaiting=True) - _commit_all(repo) - - def fake_interactive( - agent: str, model: str, effort: str, prompt: str, repo_root: Path, - ) -> int: - return 7 - - monkeypatch.setattr( - "continuous_refactoring.review_cli.run_agent_interactive", fake_interactive, - ) - - with pytest.raises(SystemExit) as exc_info: - handle_review_perform(_make_perform_args("my-mig")) - - assert exc_info.value.code == 7 - err = capsys.readouterr().err - assert "review agent exited with code 7" in err - - -def test_review_perform_exits_2_when_migration_missing( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - _init_review_project(tmp_path, monkeypatch) - - with pytest.raises(SystemExit) as exc_info: - handle_review_perform(_make_perform_args("nonexistent")) - - assert exc_info.value.code == 2 - err = capsys.readouterr().err - assert "does not exist" in err - - -def test_review_perform_exits_2_when_not_flagged_for_review( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - repo, live_dir = _setup_review_project(tmp_path, monkeypatch, awaiting=False) - - with pytest.raises(SystemExit) as exc_info: - handle_review_perform(_make_perform_args("my-mig")) - - assert exc_info.value.code == 2 - err = capsys.readouterr().err - assert "not flagged" in err - - -def test_top_level_review_perform_routes_to_migration_review_compatibility_path( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - _repo, live_dir = _setup_review_project( - tmp_path, - monkeypatch, - awaiting=True, - human_review_reason="needs approval", - ) - seen: dict[str, object] = {} - - def fake_staged_review(request: object) -> None: - seen["slug"] = request.target.slug - seen["path"] = request.target.path - seen["agent"] = request.agent - seen["model"] = request.model - seen["effort"] = request.effort - - monkeypatch.setattr( - "continuous_refactoring.review_cli.handle_staged_migration_review", - fake_staged_review, - ) - - handle_review_perform(_make_perform_args("my-mig")) - - assert seen == { - "slug": "my-mig", - "path": live_dir / "my-mig", - "agent": "codex", - "model": "test-model", - "effort": "low", - } - - -def test_review_dispatches_list_subcommand( - monkeypatch: pytest.MonkeyPatch, -) -> None: - seen: list[str] = [] - - monkeypatch.setattr( - "continuous_refactoring.review_cli.handle_review_list", - lambda: seen.append("list"), - ) - - handle_review(argparse.Namespace(review_command="list")) - - assert seen == ["list"] - - -def test_review_dispatches_perform_subcommand( - monkeypatch: pytest.MonkeyPatch, -) -> None: - seen: list[str] = [] - - def fake_perform(args: argparse.Namespace) -> None: - seen.append(args.migration) - - monkeypatch.setattr( - "continuous_refactoring.review_cli.handle_review_perform", - fake_perform, - ) - - handle_review(argparse.Namespace(review_command="perform", migration="my-mig")) - - assert seen == ["my-mig"] - - -def test_review_exits_2_without_subcommand( - capsys: pytest.CaptureFixture[str], -) -> None: - with pytest.raises(SystemExit) as exc_info: - handle_review(argparse.Namespace(review_command=None)) - - assert exc_info.value.code == 2 - err = capsys.readouterr().err - assert "Usage: continuous-refactoring review {list,perform}" in err diff --git a/tests/test_cli_taste_warning.py b/tests/test_cli_taste_warning.py index e14986a..458c14e 100644 --- a/tests/test_cli_taste_warning.py +++ b/tests/test_cli_taste_warning.py @@ -52,7 +52,7 @@ def _register_repo_with_taste( (["cr", "init"], "_handle_init"), (["cr", "taste", "--global"], "_handle_taste"), (["cr", "upgrade"], "_handle_upgrade"), - (["cr", "review", "list"], "handle_review"), + (["cr", "migration", "list"], "handle_migration"), ( [ "cr", "run-once", @@ -104,7 +104,7 @@ def xdg_root( @pytest.mark.parametrize( "argv,handler_name", _SUBCOMMANDS, - ids=["init", "taste", "upgrade", "review", "run-once", "run"], + ids=["init", "taste", "upgrade", "migration", "run-once", "run"], ) @pytest.mark.parametrize( "taste_writer,warns", diff --git a/tests/test_continuous_refactoring.py b/tests/test_continuous_refactoring.py index 005e7d4..085bfde 100644 --- a/tests/test_continuous_refactoring.py +++ b/tests/test_continuous_refactoring.py @@ -123,7 +123,7 @@ "compose_phase_execution_prompt", "compose_phase_ready_prompt", "compose_planning_prompt", - "compose_review_perform_prompt", + "compose_migration_review_prompt", "compose_scope_selection_prompt", "compose_taste_refine_prompt", "compose_taste_upgrade_prompt", diff --git a/tests/test_main_entrypoint.py b/tests/test_main_entrypoint.py index e19c998..f6cc76b 100644 --- a/tests/test_main_entrypoint.py +++ b/tests/test_main_entrypoint.py @@ -6,16 +6,17 @@ import continuous_refactoring.__main__ as main_module -def test_main_module_invokes_cli_main(monkeypatch: object) -> None: - seen: list[str] = [] +def test_main_module_invokes_cli_main_once(monkeypatch: object) -> None: + calls: list[str] = [] def fake_cli_main() -> None: - seen.append("called") + calls.append("called") monkeypatch.setattr("continuous_refactoring.cli.cli_main", fake_cli_main) monkeypatch.delitem(sys.modules, "continuous_refactoring.__main__", raising=False) runpy.run_module("continuous_refactoring", run_name="__main__") - assert seen == ["called"] + + assert calls == ["called"] def test_main_module_has_no_public_api() -> None: diff --git a/tests/test_planning.py b/tests/test_planning.py index b6da35b..285704f 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -795,6 +795,59 @@ def test_refine_ready_reopen_runs_one_revise_step( assert state.revision_base_step_counts == (5,) +def test_refine_review_two_reopens_to_revise( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo_root, live_dir, mig_root = _planning_repo_context(tmp_path, monkeypatch) + _seed_planning_snapshot( + repo_root, + live_dir, + [ + ("approaches", "completed", "Generated approaches.\n"), + ("pick-best", "completed", "Chose incremental.\n"), + ("expand", "completed", "Expanded.\n"), + ("review", "findings", "Phase 1 over-gates.\n"), + ("revise", "completed", "Revised once.\n"), + ], + plan_text="# Plan v1\n", + phase_text=_phase_doc("inventory already exists", "Inventory is current."), + ) + + result, mock = _run_refine_step( + repo_root, + live_dir, + [ + _workspace_response( + "Revised with feedback.\n", + { + "plan.md": "# Plan v2\n", + "phase-0-setup.md": _phase_doc( + "source files are present", + "Inventory is created or updated.", + ), + }, + ) + ], + monkeypatch, + feedback="Move inventory existence from precondition to DoD.", + ) + + state = load_planning_state(repo_root, planning_state_path(mig_root)) + assert result.status == "published" + assert result.step == "revise" + assert mock.stage_labels == ["revise"] + assert "User refinement feedback" in mock.prompts[0] + assert state.next_step == "review-2" + assert state.revision_base_step_counts == (5,) + assert state.feedback[-1].text == ( + "Move inventory existence from precondition to DoD." + ) + assert ( + mig_root / ".planning" / "stages" / "revise-2.stdout.md" + ).read_text(encoding="utf-8") == "Revised with feedback.\n" + + def test_refine_repeated_steps_keep_original_stdout_history( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_planning_state.py b/tests/test_planning_state.py index c0f2abf..56c107b 100644 --- a/tests/test_planning_state.py +++ b/tests/test_planning_state.py @@ -415,7 +415,7 @@ def test_planning_state_allows_null_legacy_anchor_with_current_revision_anchors( assert loaded.revision_base_step_counts == (5,) -def test_planning_state_rejects_revision_anchor_at_non_terminal_cursor( +def test_planning_state_allows_revision_anchor_at_review_cursor( tmp_path: Path, ) -> None: repo_root, mig_root = _migration_root(tmp_path) @@ -433,11 +433,9 @@ def test_planning_state_rejects_revision_anchor_at_non_terminal_cursor( payload["revision_base_step_counts"] = [3] _write_state_payload(path, payload) - with pytest.raises( - ContinuousRefactorError, - match="must point at a terminal ready cursor, got 'review'", - ): - load_planning_state(repo_root, path) + loaded = load_planning_state(repo_root, path) + assert loaded.next_step == "revise" + assert loaded.revision_base_step_counts == (3,) def test_planning_state_rejects_revision_anchor_at_skipped_terminal_cursor( @@ -462,7 +460,7 @@ def test_planning_state_rejects_revision_anchor_at_skipped_terminal_cursor( with pytest.raises( ContinuousRefactorError, - match="must point at a terminal ready cursor, got 'terminal-skipped'", + match="must point at a reopenable review cursor, got 'terminal-skipped'", ): load_planning_state(repo_root, path) diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 8e9dc05..a97f4d5 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -24,19 +24,19 @@ DEFAULT_REFACTORING_PROMPT, PHASE_EXECUTION_PROMPT, PHASE_READY_CHECK_PROMPT, + MIGRATION_REVIEW_PROMPT, PLANNING_APPROACHES_PROMPT, PLANNING_EXPAND_PROMPT, PLANNING_FINAL_REVIEW_PROMPT, PLANNING_PICK_BEST_PROMPT, PLANNING_REVIEW_PROMPT, - REVIEW_PERFORM_PROMPT, compose_full_prompt, compose_classifier_prompt, compose_interview_prompt, + compose_migration_review_prompt, compose_phase_execution_prompt, compose_phase_ready_prompt, compose_planning_prompt, - compose_review_perform_prompt, compose_taste_refine_prompt, compose_taste_upgrade_prompt, ) @@ -75,10 +75,27 @@ PLANNING_FINAL_REVIEW_PROMPT, PHASE_READY_CHECK_PROMPT, PHASE_EXECUTION_PROMPT, - REVIEW_PERFORM_PROMPT, + MIGRATION_REVIEW_PROMPT, ) +def _assert_contains_in_order(text: str, fragments: tuple[str, ...]) -> None: + cursor = 0 + for fragment in fragments: + index = text.find(fragment, cursor) + assert index != -1, f"Missing fragment: {fragment!r}" + cursor = index + len(fragment) + + +def _assert_output_contract_variants( + prompt: str, + label: str, + variants: tuple[str, ...], +) -> None: + for variant in variants: + assert f"{label}: {variant}" in prompt + + def _target() -> Target: return Target( description="Clean up auth module", @@ -134,8 +151,11 @@ def _terminal_ready_state(repo_root: Path, mig_root: Path) -> PlanningState: # --------------------------------------------------------------------------- def test_classifier_output_contract() -> None: - assert "decision: cohesive-cleanup" in CLASSIFIER_PROMPT - assert "decision: needs-plan" in CLASSIFIER_PROMPT + _assert_output_contract_variants( + CLASSIFIER_PROMPT, + "decision", + ("cohesive-cleanup", "needs-plan"), + ) def test_refactoring_prompt_defers_commit_to_driver() -> None: @@ -156,15 +176,19 @@ def test_phase_execution_prompt_has_status_block_contract() -> None: def test_final_review_output_contract() -> None: - assert "final-decision: approve-auto" in PLANNING_FINAL_REVIEW_PROMPT - assert "final-decision: approve-needs-human" in PLANNING_FINAL_REVIEW_PROMPT - assert "final-decision: reject" in PLANNING_FINAL_REVIEW_PROMPT + _assert_output_contract_variants( + PLANNING_FINAL_REVIEW_PROMPT, + "final-decision", + ("approve-auto", "approve-needs-human", "reject"), + ) def test_ready_check_output_contract() -> None: - assert "ready: yes" in PHASE_READY_CHECK_PROMPT - assert "ready: no" in PHASE_READY_CHECK_PROMPT - assert "ready: unverifiable" in PHASE_READY_CHECK_PROMPT + _assert_output_contract_variants( + PHASE_READY_CHECK_PROMPT, + "ready", + ("yes", "no", "unverifiable"), + ) def test_phase_ready_prompt_uses_precondition_terminology() -> None: @@ -262,7 +286,7 @@ def test_review_prompt_names_work_dir_and_forbids_live_dir_mutation() -> None: work_dir = Path("/xdg/projects/p/planning/auth-cleanup/review-1/work/auth-cleanup") live_dir = Path("/repo/migrations/auth-cleanup") - result = compose_review_perform_prompt( + result = compose_migration_review_prompt( "auth-cleanup", repo_root, work_dir, @@ -327,7 +351,7 @@ def test_review_and_refine_prompts_forbid_live_dir_mutation(tmp_path: Path) -> N repo_root = tmp_path / "repo" review_work_dir = tmp_path / "xdg" / "planning" / "auth-cleanup" / "review" / "work" live_mig_root = repo_root / "migrations" / "auth-cleanup" - review_prompt = compose_review_perform_prompt( + review_prompt = compose_migration_review_prompt( "auth-cleanup", repo_root, review_work_dir, @@ -529,6 +553,35 @@ def test_compose_full_prompt_includes_retry_context_heading() -> None: assert "validation failed after refactor" in result +def test_compose_full_prompt_keeps_section_order_contract() -> None: + result = compose_full_prompt( + base_prompt="BASE", + taste=_TASTE, + target=_target(), + scope_instruction="fallback scope", + validation_command="uv run pytest", + attempt=3, + retry_context="retry context", + fix_amendment="## Optional Amendment\nDetails", + ) + + _assert_contains_in_order( + result, + ( + "Attempt 3", + "BASE", + "All changes must keep the project in a state where all tests pass.", + "## Refactoring Taste", + "## Target Files", + "## Scope", + "## Validation", + "## Retry Context", + "Use this as focused context only. Do not copy raw failure text into code.", + "## Optional Amendment", + ), + ) + + def test_compose_full_prompt_omits_blank_retry_context() -> None: result = compose_full_prompt( base_prompt="base", diff --git a/tests/test_run_once.py b/tests/test_run_once.py index 9968c39..ab2fb45 100644 --- a/tests/test_run_once.py +++ b/tests/test_run_once.py @@ -39,6 +39,30 @@ def _planning_record(decision: str) -> DecisionRecord: ) +def _baseline_passes_then_refactor_fails( + test_command: str, + repo_root: Path, + stdout_path: Path, + stderr_path: Path, + **kwargs: object, +) -> CommandCapture: + if _is_baseline_validation(stdout_path): + return noop_tests( + test_command, + repo_root, + stdout_path, + stderr_path, + **kwargs, + ) + return failing_tests( + test_command, + repo_root, + stdout_path, + stderr_path, + **kwargs, + ) + + @pytest.mark.parametrize( ("kwargs", "needles"), [ @@ -193,7 +217,7 @@ def committing_agent(**kwargs: object) -> CommandCapture: validation_calls = 0 - def baseline_passes_then_fails( + def count_and_fail_after_baseline( test_command: str, repo_root: Path, stdout_path: Path, @@ -202,15 +226,7 @@ def baseline_passes_then_fails( ) -> CommandCapture: nonlocal validation_calls validation_calls += 1 - if _is_baseline_validation(stdout_path): - return noop_tests( - test_command, - repo_root, - stdout_path, - stderr_path, - **kwargs, - ) - return failing_tests( + return _baseline_passes_then_refactor_fails( test_command, repo_root, stdout_path, @@ -220,7 +236,7 @@ def baseline_passes_then_fails( monkeypatch.setattr( "continuous_refactoring.loop.run_tests", - baseline_passes_then_fails, + count_and_fail_after_baseline, ) args = make_run_once_args(run_once_env) @@ -295,32 +311,9 @@ def counting_agent(**kwargs: object) -> CommandCapture: monkeypatch.setattr("continuous_refactoring.loop.maybe_run_agent", counting_agent) - def baseline_passes_then_fails( - test_command: str, - repo_root: Path, - stdout_path: Path, - stderr_path: Path, - **kwargs: object, - ) -> CommandCapture: - if _is_baseline_validation(stdout_path): - return noop_tests( - test_command, - repo_root, - stdout_path, - stderr_path, - **kwargs, - ) - return failing_tests( - test_command, - repo_root, - stdout_path, - stderr_path, - **kwargs, - ) - monkeypatch.setattr( "continuous_refactoring.loop.run_tests", - baseline_passes_then_fails, + _baseline_passes_then_refactor_fails, ) args = make_run_once_args(run_once_env) diff --git a/tests/test_scope_candidates.py b/tests/test_scope_candidates.py index a531004..87ecd0b 100644 --- a/tests/test_scope_candidates.py +++ b/tests/test_scope_candidates.py @@ -210,3 +210,17 @@ def test_max_candidates_prunes_without_dropping_seed_candidate(tmp_path: Path) - "seed", "local-cluster", ] + + +def test_duplicate_candidate_file_sets_are_not_emitted(tmp_path: Path) -> None: + init_repo(tmp_path) + _write(tmp_path, "src/foo.py", "from .helpers import normalize\n") + _write(tmp_path, "src/helpers.py", "def normalize(value: str) -> str:\n return value\n") + _commit_all(tmp_path, "seed files") + + candidates = build_scope_candidates(_seed_target("src/foo.py"), tmp_path) + + assert [candidate.kind for candidate in candidates] == [ + "seed", + "local-cluster", + ] diff --git a/tests/test_taste_interview.py b/tests/test_taste_interview.py index 7a75d28..15c9006 100644 --- a/tests/test_taste_interview.py +++ b/tests/test_taste_interview.py @@ -32,7 +32,6 @@ def _interview_args( force: bool = False, agent: str | None = "codex", model: str | None = "m", - effort: str | None = "high", ) -> argparse.Namespace: return make_taste_args( "interview", @@ -40,7 +39,6 @@ def _interview_args( force=force, agent=agent, model=model, - effort=effort, ) # --------------------------------------------------------------------------- @@ -53,7 +51,7 @@ def test_interview_requires_agent_flags( capsys: pytest.CaptureFixture[str], ) -> None: monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) - args = _interview_args(agent=None, model=None, effort=None) + args = _interview_args(agent=None, model=None) with pytest.raises(SystemExit) as exc_info: _handle_taste(args) assert exc_info.value.code == 2 @@ -67,10 +65,11 @@ def test_interview_rejects_agent_flags_without_interview( ) -> None: monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) with pytest.raises(SystemExit) as exc_info: - _handle_taste(make_taste_args(agent="codex", model="m", effort="high")) + _handle_taste(make_taste_args(agent="codex", model="m")) assert exc_info.value.code == 2 err = capsys.readouterr().err assert "require --interview, --upgrade, or --refine" in err + assert "--effort" not in err def test_force_requires_interview( @@ -285,6 +284,7 @@ def test_interview_prompt_includes_existing_content( _handle_taste(_interview_args(force=True)) prompt = captured["prompt"] + assert captured["effort"] == "medium" assert "Existing taste content" in prompt assert "starting draft" in prompt assert existing.strip() in prompt @@ -307,11 +307,24 @@ def test_taste_subparser_accepts_interview_flags() -> None: args = parser.parse_args( [ "taste", "--interview", "--with", "codex", - "--model", "gpt-x", "--effort", "xhigh", "--force", + "--model", "gpt-x", "--force", ] ) assert args.interview is True assert args.agent == "codex" assert args.model == "gpt-x" - assert args.effort == "xhigh" assert args.force is True + + +@pytest.mark.parametrize("mode", [None, "--interview", "--upgrade", "--refine"]) +def test_taste_subparser_rejects_effort(mode: str | None) -> None: + parser = build_parser() + argv = ["taste"] + if mode is not None: + argv.append(mode) + argv.extend(["--with", "codex", "--model", "gpt-x", "--effort", "high"]) + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args(argv) + + assert exc_info.value.code == 2 diff --git a/tests/test_taste_refine.py b/tests/test_taste_refine.py index 7b57e56..1fb964b 100644 --- a/tests/test_taste_refine.py +++ b/tests/test_taste_refine.py @@ -15,7 +15,6 @@ def _refine_args( global_: bool = False, agent: str | None = "codex", model: str | None = "m", - effort: str | None = "high", force: bool = False, ) -> argparse.Namespace: return make_taste_args( @@ -23,7 +22,6 @@ def _refine_args( global_=global_, agent=agent, model=model, - effort=effort, force=force, ) @@ -34,7 +32,7 @@ def test_refine_requires_agent_flags( capsys: pytest.CaptureFixture[str], ) -> None: monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) - args = _refine_args(agent=None, model=None, effort=None) + args = _refine_args(agent=None, model=None) with pytest.raises(SystemExit) as exc_info: _handle_taste(args) @@ -139,6 +137,7 @@ def test_refine_prompt_allows_open_ended_improvement_and_explicit_write_handoff( assert "do not modify either file again" in prompt assert "Do not add one unless the user explicitly asks for it" in prompt assert "- Keep helpers honest." in prompt + assert captured["effort"] == "medium" assert captured["content_path"] == str(taste_path) assert captured["settle_path"] == str(taste_path.with_name("taste.md.done")) @@ -146,7 +145,7 @@ def test_refine_prompt_allows_open_ended_improvement_and_explicit_write_handoff( def test_taste_subparser_accepts_refine_flags() -> None: parser = build_parser() args = parser.parse_args( - ["taste", "--refine", "--with", "codex", "--model", "gpt-x", "--effort", "xhigh"], + ["taste", "--refine", "--with", "codex", "--model", "gpt-x"], ) assert args.refine is True @@ -154,7 +153,6 @@ def test_taste_subparser_accepts_refine_flags() -> None: assert args.upgrade is False assert args.agent == "codex" assert args.model == "gpt-x" - assert args.effort == "xhigh" @pytest.mark.parametrize("other_mode", ["--interview", "--upgrade"]) diff --git a/tests/test_taste_upgrade.py b/tests/test_taste_upgrade.py index d1dbbcd..7bb505c 100644 --- a/tests/test_taste_upgrade.py +++ b/tests/test_taste_upgrade.py @@ -27,7 +27,6 @@ def _upgrade_args( global_: bool = False, agent: str | None = "codex", model: str | None = "m", - effort: str | None = "high", force: bool = False, ) -> argparse.Namespace: return make_taste_args( @@ -35,7 +34,6 @@ def _upgrade_args( global_=global_, agent=agent, model=model, - effort=effort, force=force, ) @@ -112,6 +110,7 @@ def test_upgrade_prompt_mentions_legacy_and_new_dimensions( _handle_taste(_upgrade_args()) prompt = captured["prompt"] + assert captured["effort"] == "medium" assert "no version header" in prompt assert "legacy" in prompt.lower() assert "large-scope decisions" in prompt @@ -135,7 +134,7 @@ def test_upgrade_requires_agent_flags_when_stale( taste_path.write_text("- Legacy.\n", encoding="utf-8") with pytest.raises(SystemExit) as exc_info: - _handle_taste(_upgrade_args(agent=None, model=None, effort=None)) + _handle_taste(_upgrade_args(agent=None, model=None)) assert exc_info.value.code == 2 err = capsys.readouterr().err @@ -154,7 +153,7 @@ def test_upgrade_requires_agent_flags_when_global_taste_is_stale( with pytest.raises(SystemExit) as exc_info: _handle_taste( - _upgrade_args(global_=True, agent=None, model=None, effort=None), + _upgrade_args(global_=True, agent=None, model=None), ) assert exc_info.value.code == 2 @@ -169,7 +168,7 @@ def test_upgrade_noop_skips_agent_flag_check( taste_path = init_taste_project(tmp_path, monkeypatch) taste_path.write_text(default_taste_text(), encoding="utf-8") - _handle_taste(_upgrade_args(agent=None, model=None, effort=None)) + _handle_taste(_upgrade_args(agent=None, model=None)) out = capsys.readouterr().out.strip() assert "taste already current" in out @@ -199,11 +198,12 @@ def test_upgrade_rejects_force( def test_taste_subparser_accepts_upgrade_flags() -> None: parser = build_parser() args = parser.parse_args( - ["taste", "--upgrade", "--with", "codex", "--model", "m", "--effort", "high"], + ["taste", "--upgrade", "--with", "codex", "--model", "m"], ) assert args.upgrade is True assert args.interview is False assert args.agent == "codex" + assert args.model == "m" def test_taste_interview_and_upgrade_mutually_exclusive() -> None: diff --git a/uv.lock b/uv.lock index 1b32d35..e6f83e8 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "continuous-refactoring" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } [package.dev-dependencies]