diff --git a/RPG-Kit/README.hi-IN.md b/RPG-Kit/README.hi-IN.md index 86b24fc..8fed00d 100644 --- a/RPG-Kit/README.hi-IN.md +++ b/RPG-Kit/README.hi-IN.md @@ -137,11 +137,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K /rpgkit.feature_build /rpgkit.feature_refactor [Optional] /rpgkit.feature_edit - /rpgkit.build_skeleton - /rpgkit.build_data_flow - /rpgkit.design_base_classes - /rpgkit.design_interfaces - /rpgkit.plan_tasks + /rpgkit.plan /rpgkit.code_gen [Optional] /rpgkit.rpg_edit ``` @@ -241,7 +237,7 @@ rpgkit update ## आगामी सुविधाएँ -- **सरल जनरेशन कमांड्स:** वर्तमान बहु-चरण जनरेशन प्रवाह को कम कमांड्स में मर्ज किया जाएगा, जैसे `/rpgkit.generate_repo`, `/rpgkit.generate_feature` और `/rpgkit.plan`। +- **सरल जनरेशन कमांड्स:** वर्तमान बहु-चरण जनरेशन प्रवाह को कम कमांड्स में मर्ज किया जाएगा, जैसे `/rpgkit.generate_repo` और `/rpgkit.generate_feature`। `/rpgkit.plan` 0.1.4 में रिलीज़ हो चुका है। - **बहु-भाषा समर्थन:** Go, C++, Rust, JavaScript/TypeScript और अन्य के लिए समर्थन जोड़ा जाएगा। - **अधिक प्लेटफ़ॉर्म एकीकरण:** विभिन्न सिस्टम्स पर विभिन्न AI कोडिंग एजेंट्स के लिए CLI और VS Code एक्सटेंशन वर्कफ़्लो में RPG-Kit समर्थन। diff --git a/RPG-Kit/README.ja-JP.md b/RPG-Kit/README.ja-JP.md index 6da085b..e8ab894 100644 --- a/RPG-Kit/README.ja-JP.md +++ b/RPG-Kit/README.ja-JP.md @@ -137,11 +137,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K /rpgkit.feature_build /rpgkit.feature_refactor [Optional] /rpgkit.feature_edit - /rpgkit.build_skeleton - /rpgkit.build_data_flow - /rpgkit.design_base_classes - /rpgkit.design_interfaces - /rpgkit.plan_tasks + /rpgkit.plan /rpgkit.code_gen [Optional] /rpgkit.rpg_edit ``` @@ -241,7 +237,7 @@ rpgkit update ## 今後の機能 -- **よりシンプルな生成コマンド:** 現在の多段階の生成フローを、`/rpgkit.generate_repo`、`/rpgkit.generate_feature`、`/rpgkit.plan` などのより少ないコマンドにまとめます。 +- **よりシンプルな生成コマンド:** 現在の多段階の生成フローを、`/rpgkit.generate_repo` や `/rpgkit.generate_feature` などのより少ないコマンドにまとめます。`/rpgkit.plan` は 0.1.4 でリリース済みです。 - **多言語サポート:** Go、C++、Rust、JavaScript/TypeScript などのサポートを追加します。 - **より多くのプラットフォーム連携:** さまざまなシステム上の異なる AI コーディングエージェントについて、CLI と VS Code 拡張ワークフローを横断して RPG-Kit をサポートします。 diff --git a/RPG-Kit/README.ko-KR.md b/RPG-Kit/README.ko-KR.md index c770511..1ff6aac 100644 --- a/RPG-Kit/README.ko-KR.md +++ b/RPG-Kit/README.ko-KR.md @@ -137,11 +137,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K /rpgkit.feature_build /rpgkit.feature_refactor [Optional] /rpgkit.feature_edit - /rpgkit.build_skeleton - /rpgkit.build_data_flow - /rpgkit.design_base_classes - /rpgkit.design_interfaces - /rpgkit.plan_tasks + /rpgkit.plan /rpgkit.code_gen [Optional] /rpgkit.rpg_edit ``` @@ -241,7 +237,7 @@ rpgkit update ## 예정된 기능 -- **더 간단한 생성 커맨드:** 현재의 다단계 생성 흐름을 `/rpgkit.generate_repo`, `/rpgkit.generate_feature`, `/rpgkit.plan` 등 더 적은 커맨드로 통합합니다. +- **더 간단한 생성 커맨드:** 현재의 다단계 생성 흐름을 `/rpgkit.generate_repo`, `/rpgkit.generate_feature` 등 더 적은 커맨드로 통합합니다. `/rpgkit.plan` 은 0.1.4 에서 출시되었습니다. - **다국어 지원:** Go, C++, Rust, JavaScript/TypeScript 등을 추가로 지원합니다. - **더 많은 플랫폼 통합:** 다양한 시스템에서 서로 다른 AI 코딩 에이전트의 CLI 및 VS Code 확장 워크플로에 걸쳐 RPG-Kit을 지원합니다. diff --git a/RPG-Kit/README.md b/RPG-Kit/README.md index ea45078..9e070e7 100644 --- a/RPG-Kit/README.md +++ b/RPG-Kit/README.md @@ -137,11 +137,7 @@ Use this path when you want RPG-Kit to turn requirements into a new codebase. /rpgkit.feature_build /rpgkit.feature_refactor [Optional] /rpgkit.feature_edit - /rpgkit.build_skeleton - /rpgkit.build_data_flow - /rpgkit.design_base_classes - /rpgkit.design_interfaces - /rpgkit.plan_tasks + /rpgkit.plan /rpgkit.code_gen [Optional] /rpgkit.rpg_edit ``` @@ -241,7 +237,7 @@ rpgkit update ## Upcoming Features -- **Simpler generation commands:** merge the current multi-step generation flow into fewer commands, such as `/rpgkit.generate_repo`, `/rpgkit.generate_feature`, and `/rpgkit.plan`. +- **Simpler generation commands:** merge the current multi-step generation flow into fewer commands, such as `/rpgkit.generate_repo` and `/rpgkit.generate_feature`. `/rpgkit.plan` has shipped in 0.1.4. - **Multi-language support:** add support for Go, C++, Rust, JavaScript/TypeScript, and more. - **More platform integrations:** support RPG-Kit across CLI and VS Code extension workflows for different AI coding agents on different systems. diff --git a/RPG-Kit/README.zh-CN.md b/RPG-Kit/README.zh-CN.md index 90ffcdb..f9254fd 100644 --- a/RPG-Kit/README.zh-CN.md +++ b/RPG-Kit/README.zh-CN.md @@ -137,11 +137,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K /rpgkit.feature_build /rpgkit.feature_refactor [Optional] /rpgkit.feature_edit - /rpgkit.build_skeleton - /rpgkit.build_data_flow - /rpgkit.design_base_classes - /rpgkit.design_interfaces - /rpgkit.plan_tasks + /rpgkit.plan /rpgkit.code_gen [Optional] /rpgkit.rpg_edit ``` @@ -241,7 +237,7 @@ rpgkit update ## 即将推出的功能 -- **更简化的生成命令**:把当前多步骤的生成流程合并为更少的命令,例如 `/rpgkit.generate_repo`、`/rpgkit.generate_feature` 和 `/rpgkit.plan`。 +- **更简化的生成命令**:把当前多步骤的生成流程合并为更少的命令,例如 `/rpgkit.generate_repo` 和 `/rpgkit.generate_feature`。`/rpgkit.plan` 已在 0.1.4 中发布。 - **多语言支持**:增加对 Go、C++、Rust、JavaScript/TypeScript 等的支持。 - **更多平台集成**:在不同系统上跨 CLI 和 VS Code 扩展工作流支持不同的 AI 编码智能体。 diff --git a/RPG-Kit/docs/commands.md b/RPG-Kit/docs/commands.md index 9f712c9..b5741ac 100644 --- a/RPG-Kit/docs/commands.md +++ b/RPG-Kit/docs/commands.md @@ -1,6 +1,6 @@ # /rpgkit Commands Reference -RPG-Kit provides 13 slash commands that work in three paths: +RPG-Kit provides 15 slash commands that work in three paths: - **Forward pipeline:** Requirements → Repository Planning Graph (RPG) → Code - **Reverse encoder:** Existing code → RPG @@ -14,21 +14,31 @@ RPG-Kit provides 13 slash commands that work in three paths: | Command | Description | | ------- | ----------- | +| `/rpgkit.feature_construct ` | Run Phase 1 feature specification, build, and refactor in one step — recommended | | `/rpgkit.feature_spec ` | Create structured feature specifications from user input or `docs/` files | | `/rpgkit.feature_build` | Generate and expand the feature tree from specifications | | `/rpgkit.feature_refactor` | Refactor feature tree into modular component architecture | | `/rpgkit.feature_edit ` | Edit feature tree nodes before skeleton planning — optional | +> `/rpgkit.feature_construct` is the simplest way to drive Phase 1 end-to-end. Use +> the individual Phase 1 commands only when you want to debug or re-run a +> specific stage. + ### Phase 2: RPG Construction and Planning | Command | Description | | ------- | ----------- | +| `/rpgkit.plan` | Run all five Phase-2 stages in one step with automatic resume — recommended | | `/rpgkit.build_skeleton` | Build repository file skeleton from component architecture; creates `.rpgkit/data/rpg.json` | | `/rpgkit.build_data_flow` | Build inter-component data flow DAG and update the RPG | | `/rpgkit.design_base_classes` | Design shared base classes and data structures | | `/rpgkit.design_interfaces` | Design function/class interfaces with type hints and docstrings | | `/rpgkit.plan_tasks` | Plan dependency-ordered implementation task batches | +> `/rpgkit.plan` is the simplest way to drive Phase 2 end-to-end. Use +> the individual commands above only when you want to debug or +> re-run a specific stage. + ### Phase 3: Code Generation and Surgical Edits | Command | Description | @@ -49,6 +59,51 @@ Both directions produce the same RPG structure at `.rpgkit/data/rpg.json`, enabl ## Phase 1: Feature Specification +### `/rpgkit.feature_construct` + +Run the full Phase 1 pipeline (`feature_spec` → `feature_build` → `feature_refactor`) in one step. This is the recommended entry point for creating the feature tree that feeds `/rpgkit.plan`. + +**Input modes:** + +- **Direct input:** provide requirements after the command. +- **Auto-detect:** omit input to use existing `docs/*.md` files automatically. +- **Inline prompt:** if neither direct input nor usable docs exist, the command asks for requirements and then continues in the same flow. + +**Output:** every artifact produced by the three individual Phase 1 commands — `.rpgkit/data/feature_spec/`, `feature_spec.json`, `feature_build.json`, and `feature_tree.json`. + +**Process:** + +1. **Probe progress** — runs `rpgkit script feature_construct.py --check-only --json` to see which Phase 1 stages already have valid artifacts. +2. **Generate requirements artifacts when needed** — follows the `/rpgkit.feature_spec` workflow for direct text or `docs/*.md` sources. +3. **Run/resume** — executes `rpgkit script feature_construct.py`, skipping completed stages and cascading downstream rebuilds when an upstream stage reruns. +4. **Optional expansion** — after completion, the user can expand features through the existing `feature_build --mode suggest-directions` and `--mode step2 --direction ` flow; refactor is rerun afterward so `feature_tree.json` stays aligned. + +**CLI flags forwarded after `$ARGUMENTS`:** + +- `--check-only` — show Phase 1 progress without modifying artifacts or running stages. +- `--json` — with `--check-only`, emit the progress report as JSON. +- `--force` — rebuild all Phase 1 stages. +- `--dry-run` — print the commands that would run without modifying artifacts. +- `--verbose` — forward native verbose logging flags. +- `--no-trajectory` — disable trajectory recording where supported. +- `--max-iter-refactor N` — forward to `feature_refactor.py` as `--max-iterations N`. +- `--review-threshold N` — forward to `feature_build.py --mode step1`. +- `--review-max-iterations N` — forward to `feature_build.py --mode step1`. + +Use `--` to separate options from requirement text: + +```text +/rpgkit.feature_construct --check-only +/rpgkit.feature_construct --check-only --json +/rpgkit.feature_construct --review-threshold 99 -- Build a CLI tool for managing Docker containers +/rpgkit.feature_construct Build a CLI tool for managing Docker containers +/rpgkit.feature_construct # Auto-detect docs/ files +``` + +**Next step:** `/rpgkit.plan` is the default handoff after Phase 1. If the final tree needs small adjustments, run `/rpgkit.feature_edit `. The granular Phase 1 commands remain available for debug and surgical reruns; `/rpgkit.build_skeleton` is a Phase 2 granular fallback, not the default next step. + +--- + ### `/rpgkit.feature_spec` Create structured feature specifications from user input or documentation files. @@ -159,6 +214,69 @@ Edit feature tree nodes before repository planning begins. ## Phase 2: RPG Construction and Planning +### `/rpgkit.plan` + +Run the full Phase-2 pipeline (`build_skeleton` → `build_data_flow` → +`design_base_classes` → `design_interfaces` → `plan_tasks`) in one +step. This is the recommended entry point for Phase 2. + +**Input:** `~/.rpgkit/workspaces//data/feature_tree.json` (produced by +`/rpgkit.feature_construct` or `/rpgkit.feature_refactor`) + +**Output:** every artifact produced by the five individual commands — +`skeleton.json`, `data_flow.json`, `base_classes.json`, +`interfaces.json`, `tasks.json`, plus `rpg.json` and the +`data_flow_viz.html` visualization. + +**Process:** + +1. **Probe progress** — runs `rpgkit script plan.py --check-only --json` + to see which stages already have valid artifacts. +2. **Decide** — based on the probe result, the command prompts you + **once** with one of three options: + - All five stages already done → `Overwrite` or `Exit`. + - Partial progress (some stages done) → `Continue`, `Restart`, or `Exit`. + - Fresh workspace → no prompt; runs the full pipeline. +3. **Run** — executes the chosen mode through `rpgkit script plan.py`. + Each stage's stdout is streamed live and also written to a per-stage + log under `~/.rpgkit/workspaces//logs/`. +4. **Verify** — after every stage's build script, the corresponding + `check_*.py` script re-runs to validate the produced artifact. If + verification fails the pipeline stops and prints recovery hints. + +**Resume semantics:** the command treats `type == "update"` from each +`check_*.py` as the source of truth for "this stage is done". If you +press Ctrl-C halfway through, running `/rpgkit.plan` again automatically +resumes from the first not-done stage. When any earlier stage is +re-run, every downstream stage is rebuilt too so artifacts never drift +apart. + +**CLI flags forwarded after `$ARGUMENTS`:** + +- `--force` — discard existing artifacts and rebuild every stage. +- `--max-iter-skeleton N`, `--max-iter-data-flow N`, + `--max-iter-base-classes N`, `--max-iter-interfaces N` — + override iteration counts for the corresponding stage. +- `--verbose` — forward `--verbose` to every sub-script. +- `--no-trajectory` — forward `--no-trajectory` where supported. + +**Examples:** + +```text +/rpgkit.plan +/rpgkit.plan --verbose +/rpgkit.plan --force # rebuild everything +/rpgkit.plan --max-iter-skeleton 15 +``` + +To inspect progress without running anything: + +```bash +rpgkit script plan.py --check-only +``` + +--- + ### `/rpgkit.build_skeleton` Build the repository file skeleton from the component architecture. This is where the forward pipeline first creates the RPG. @@ -462,10 +580,10 @@ All intermediate data is stored in `.rpgkit/data/`: | File | Produced by | Description | | ---- | ----------- | ----------- | -| `feature_spec/` | `feature_spec` | Evidence and feature specification documents | -| `feature_spec.json` | `feature_spec` | Structured feature specification | -| `feature_build.json` | `feature_build` | Expanded feature tree | -| `feature_tree.json` | `feature_refactor` / `feature_edit` | Component architecture | +| `feature_spec/` | `feature_construct` / `feature_spec` | Evidence and feature specification documents | +| `feature_spec.json` | `feature_construct` / `feature_spec` | Structured feature specification | +| `feature_build.json` | `feature_construct` / `feature_build` | Expanded feature tree | +| `feature_tree.json` | `feature_construct` / `feature_refactor` / `feature_edit` | Component architecture | | `skeleton.json` | `build_skeleton` | File skeleton | | `skeleton_summary.txt` | `build_skeleton` | Human-readable skeleton summary | | `rpg.json` | `build_skeleton` / `encode`, then updated by later commands | Repository Planning Graph | diff --git a/RPG-Kit/scripts/check_base_classes.py b/RPG-Kit/scripts/check_base_classes.py index f04e6b1..8e99ad5 100644 --- a/RPG-Kit/scripts/check_base_classes.py +++ b/RPG-Kit/scripts/check_base_classes.py @@ -108,14 +108,14 @@ def inspect_state(base_classes_path: Path) -> Dict[str, Any]: """Inspect current state and determine action needed. Returns dict with: - - state: "error" | "init" | "update" + - type: "error" | "init" | "update" - message: description - details: additional info """ # Check if base_classes.json exists if not base_classes_path.exists(): return { - "state": "init", + "type": "init", "message": "base_classes.json not found - need to run design_base_classes", "details": {} } @@ -126,7 +126,7 @@ def inspect_state(base_classes_path: Path) -> Dict[str, Any]: data = json.load(f) except json.JSONDecodeError as e: return { - "state": "error", + "type": "error", "message": f"Invalid JSON in base_classes.json: {e}", "details": {} } @@ -134,7 +134,7 @@ def inspect_state(base_classes_path: Path) -> Dict[str, Any]: # Check for error field if "error" in data: return { - "state": "error", + "type": "error", "message": f"Base classes has error: {data['error']}", "details": {} } @@ -143,7 +143,7 @@ def inspect_state(base_classes_path: Path) -> Dict[str, Any]: is_valid, errors = validate_base_classes_structure(data) if not is_valid: return { - "state": "error", + "type": "error", "message": "Base classes structure or syntax is invalid", "details": {"errors": errors} } @@ -161,7 +161,7 @@ def inspect_state(base_classes_path: Path) -> Dict[str, Any]: ds_file_paths = [ds.get("file_path", "") for ds in data_structures if ds.get("file_path")] return { - "state": "update", + "type": "update", "message": "Base classes are valid", "details": { "file_count": len(base_classes), @@ -178,7 +178,7 @@ def inspect_state(base_classes_path: Path) -> Dict[str, Any]: def print_state(result: Dict[str, Any]) -> None: """Print state information.""" - state = result["state"] + state = result["type"] message = result["message"] details = result.get("details", {}) @@ -259,7 +259,7 @@ def main(): result = inspect_state(args.input) # In verbose mode, include raw base_classes data - if args.verbose and result.get("state") == "update": + if args.verbose and result.get("type") == "update": base_classes_data = load_json(args.input) if base_classes_data: result["base_classes"] = base_classes_data.get("base_classes", []) @@ -273,7 +273,7 @@ def main(): print_state(result) # Return exit code based on state - if result["state"] == "error": + if result["type"] == "error": return 1 return 0 diff --git a/RPG-Kit/scripts/check_data_flow.py b/RPG-Kit/scripts/check_data_flow.py index 84455dd..281e83e 100644 --- a/RPG-Kit/scripts/check_data_flow.py +++ b/RPG-Kit/scripts/check_data_flow.py @@ -168,14 +168,14 @@ def inspect_state(data_flow_path: Path, skeleton_path: Path) -> Dict[str, Any]: """Inspect current state and determine action needed. Returns dict with: - - state: "error" | "init" | "warning" | "update" + - type: "error" | "init" | "warning" | "update" - message: description - details: additional info """ # Check if data_flow.json exists if not data_flow_path.exists(): return { - "state": "init", + "type": "init", "message": "data_flow.json not found - need to run build_data_flow", "details": {} } @@ -186,7 +186,7 @@ def inspect_state(data_flow_path: Path, skeleton_path: Path) -> Dict[str, Any]: data_flow = json.load(f) except json.JSONDecodeError as e: return { - "state": "error", + "type": "error", "message": f"Invalid JSON in data_flow.json: {e}", "details": {} } @@ -194,7 +194,7 @@ def inspect_state(data_flow_path: Path, skeleton_path: Path) -> Dict[str, Any]: # Check for error field if "error" in data_flow: return { - "state": "error", + "type": "error", "message": f"Data flow has error: {data_flow['error']}", "details": {} } @@ -203,7 +203,7 @@ def inspect_state(data_flow_path: Path, skeleton_path: Path) -> Dict[str, Any]: is_valid, errors = validate_data_flow_structure(data_flow) if not is_valid: return { - "state": "error", + "type": "error", "message": "Data flow structure is invalid", "details": {"errors": errors} } @@ -223,14 +223,14 @@ def inspect_state(data_flow_path: Path, skeleton_path: Path) -> Dict[str, Any]: if not is_consistent: return { - "state": "warning", + "type": "warning", "message": "Component mismatch between skeleton and data flow", "details": xval_details } # All good return { - "state": "update", + "type": "update", "message": "Data flow is valid and consistent", "details": { "edge_count": len(data_flow.get("data_flow", [])), @@ -242,7 +242,7 @@ def inspect_state(data_flow_path: Path, skeleton_path: Path) -> Dict[str, Any]: except Exception as e: # Skeleton load failed, just validate data flow return { - "state": "update", + "type": "update", "message": f"Data flow is valid (skeleton check skipped: {e})", "details": { "edge_count": len(data_flow.get("data_flow", [])), @@ -252,7 +252,7 @@ def inspect_state(data_flow_path: Path, skeleton_path: Path) -> Dict[str, Any]: # No skeleton to compare return { - "state": "update", + "type": "update", "message": "Data flow is valid (no skeleton to cross-validate)", "details": { "edge_count": len(data_flow.get("data_flow", [])), @@ -263,7 +263,7 @@ def inspect_state(data_flow_path: Path, skeleton_path: Path) -> Dict[str, Any]: def print_state(result: Dict[str, Any]) -> None: """Print state information.""" - state = result["state"] + state = result["type"] message = result["message"] details = result.get("details", {}) @@ -338,7 +338,7 @@ def main(): result = inspect_state(args.data_flow, args.skeleton) # In verbose mode, include all edges and component details - if args.verbose and result.get("state") == "update": + if args.verbose and result.get("type") == "update": data_flow_data = load_json(args.data_flow) if data_flow_data: result["edges"] = data_flow_data.get("data_flow", []) @@ -353,7 +353,7 @@ def main(): print_state(result) # Print verbose details - if args.verbose and result.get("state") == "update": + if args.verbose and result.get("type") == "update": edges = result.get("edges", []) if edges: print("\nData Flow Edges:") @@ -365,7 +365,7 @@ def main(): print(f"\nSubtree Order: {' → '.join(subtree_order)}") # Return exit code based on state - if result["state"] == "error": + if result["type"] == "error": return 1 return 0 diff --git a/RPG-Kit/scripts/check_skeleton.py b/RPG-Kit/scripts/check_skeleton.py index 8c2c692..e7bb709 100644 --- a/RPG-Kit/scripts/check_skeleton.py +++ b/RPG-Kit/scripts/check_skeleton.py @@ -385,7 +385,15 @@ def main() -> None: action="store_true", help="Include detailed file list and all feature mismatches" ) - + parser.add_argument( + "--json", + action="store_true", + help=( + "Accepted for compatibility with the unified check_*.py contract used by " + "plan.py; this script already prints JSON unconditionally." + ), + ) + args = parser.parse_args() result = inspect_state() diff --git a/RPG-Kit/scripts/feature_construct.py b/RPG-Kit/scripts/feature_construct.py new file mode 100644 index 0000000..72bc970 --- /dev/null +++ b/RPG-Kit/scripts/feature_construct.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +"""Phase 1 feature construction facade orchestrator.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import signal +import subprocess +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +from common.paths import DATA_DIR +from common.paths import FEATURE_BUILD_FILE as _FEATURE_BUILD_FILE +from common.paths import FEATURE_SPEC_FILE as _FEATURE_SPEC_FILE +from common.paths import FEATURE_TREE_FILE as _FEATURE_TREE_FILE + +_SCRIPTS_DIR = Path(__file__).resolve().parent + +FEATURE_SPEC_FILE = _FEATURE_SPEC_FILE +FEATURE_BUILD_FILE = _FEATURE_BUILD_FILE +FEATURE_TREE_FILE = _FEATURE_TREE_FILE + +_LOGICAL_PATHS = { + "feature_spec": ".rpgkit/data/feature_spec.json", + "feature_build": ".rpgkit/data/feature_build.json", + "feature_refactor": ".rpgkit/data/feature_tree.json", +} + +_RESET_REASONS = {"forced", "upstream rebuilt"} + +_REQUIRED_FEATURE_SPEC_FIELDS = ( + "meta", + "repository_name", + "repository_purpose", + "background_and_overview", + "functional_requirements", + "non_functional_requirements", +) + + +@dataclass(frozen=True) +class Stage: + name: str + build_script: str + + +STAGES: tuple[Stage, ...] = ( + Stage(name="feature_spec", build_script="feature_spec_to_json.py"), + Stage(name="feature_build", build_script="feature_build.py"), + Stage(name="feature_refactor", build_script="feature_refactor.py"), +) + + +@dataclass +class StageState: + stage: Stage + type: str = "init" + message: str = "" + done: bool = False + will_run: bool = False + reason: str = "" + raw: dict[str, Any] = field(default_factory=dict) + + +def _resolve_invoker() -> list[str]: + rpgkit = shutil.which("rpgkit") + if rpgkit: + return [rpgkit, "script"] + return [sys.executable] + + +def _script_argv(invoker: list[str], script_name: str) -> list[str]: + if Path(invoker[0]).name == "rpgkit": + return [*invoker, script_name] + return [*invoker, str(_SCRIPTS_DIR / script_name)] + + +def _run_stage(invoker: list[str], script_name: str, extra: list[str]) -> int: + argv = [*_script_argv(invoker, script_name), *extra] + proc = subprocess.run(argv, check=False) + return proc.returncode + + +def _load_json_object(path: Path) -> tuple[Optional[dict[str, Any]], Optional[str]]: + try: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except FileNotFoundError: + return None, "missing" + except json.JSONDecodeError as exc: + return None, f"invalid JSON: {exc.msg}" + except OSError as exc: + return None, f"cannot read file: {exc}" + + if not isinstance(data, dict): + return None, "JSON root must be an object" + return data, None + + +def _has_content(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, (list, dict)): + return bool(value) + return True + + +def _state(stage: Stage, type_: str, message: str, raw: Optional[dict[str, Any]] = None) -> StageState: + return StageState( + stage=stage, + type=type_, + message=message, + done=(type_ == "update"), + raw=raw or {}, + ) + + +def _check_feature_spec(stage: Stage) -> StageState: + data, error = _load_json_object(FEATURE_SPEC_FILE) + logical = _LOGICAL_PATHS[stage.name] + if error == "missing": + return _state(stage, "init", f"{logical} is missing") + if error: + return _state(stage, "warning", f"{logical} is not complete: {error}") + + missing = [field for field in _REQUIRED_FEATURE_SPEC_FIELDS if not _has_content(data.get(field))] + if missing: + return _state( + stage, + "warning", + f"{logical} is missing required fields: {', '.join(missing)}", + {"missing_fields": missing}, + ) + return _state(stage, "update", f"{logical} is valid") + + +def _check_feature_build(stage: Stage) -> StageState: + data, error = _load_json_object(FEATURE_BUILD_FILE) + logical = _LOGICAL_PATHS[stage.name] + if error == "missing": + return _state(stage, "init", f"{logical} is missing") + if error: + return _state(stage, "warning", f"{logical} is not complete: {error}") + return _state(stage, "update", f"{logical} is valid JSON", {"keys": sorted(data.keys())}) + + +def _check_feature_refactor(stage: Stage) -> StageState: + data, error = _load_json_object(FEATURE_TREE_FILE) + logical = _LOGICAL_PATHS[stage.name] + if error == "missing": + return _state(stage, "init", f"{logical} is missing") + if error: + return _state(stage, "warning", f"{logical} is not complete: {error}") + + components = data.get("components") + if isinstance(components, (list, dict)) and components: + return _state(stage, "update", f"{logical} has components") + return _state(stage, "warning", f"{logical} has no non-empty components collection") + + +def _check_stage(stage: Stage) -> StageState: + if stage.name == "feature_spec": + return _check_feature_spec(stage) + if stage.name == "feature_build": + return _check_feature_build(stage) + if stage.name == "feature_refactor": + return _check_feature_refactor(stage) + return _state(stage, "error", f"unknown stage: {stage.name}") + + +def probe() -> list[StageState]: + return [_check_stage(stage) for stage in STAGES] + + +def decide(states: list[StageState], force: bool) -> None: + cascade = False + for state in states: + if force: + state.will_run = True + state.reason = "forced" + continue + if cascade: + state.will_run = True + state.reason = "upstream rebuilt" + continue + if state.type == "update": + state.will_run = False + state.reason = "up-to-date" + else: + state.will_run = True + state.reason = f"type={state.type}" + cascade = True + + +_GLYPH = {"update": "✓", "init": "·", "warning": "!", "error": "✗"} + + +def _format_table(states: list[StageState]) -> str: + rows = ["Stage Type Done Action"] + rows.append("-" * 52) + for state in states: + glyph = _GLYPH.get(state.type, "?") + action = "run" if state.will_run else "skip" + rows.append( + f"{state.stage.name:<16} {glyph} {state.type:<8} " + f"{'yes' if state.done else 'no ':<3} {action}" + ) + return "\n".join(rows) + + +def _print_probe_summary(states: list[StageState]) -> None: + done = sum(1 for state in states if state.done) + total = len(states) + first_pending = next((state.stage.name for state in states if not state.done), None) + print(f"Feature construction progress: {done}/{total} stages complete.") + if first_pending: + print(f"Next pending stage: {first_pending}") + else: + print("All Phase 1 stages are up-to-date.") + print() + print(_format_table(states)) + + +def _check_only_payload(states: list[StageState]) -> dict[str, Any]: + done = sum(1 for state in states if state.done) + total = len(states) + next_pending = next((state.stage.name for state in states if not state.done), None) + return { + "total": total, + "done": done, + "completed": done, + "next": next_pending, + "stages": [ + { + "name": state.stage.name, + "type": state.type, + "message": state.message, + "done": state.done, + "will_run": state.will_run, + "reason": state.reason, + } + for state in states + ], + } + + +def _emit_check_only_json(states: list[StageState]) -> None: + print(json.dumps(_check_only_payload(states), indent=2)) + + +def _format_number(value: float) -> str: + if value.is_integer(): + return str(int(value)) + return str(value) + + +def _build_args_for(stage: Stage, args: argparse.Namespace) -> list[str]: + extra: list[str] = [] + if stage.name == "feature_build": + extra.extend(["--mode", "step1"]) + if args.review_threshold is not None: + extra.extend(["--review-threshold", _format_number(args.review_threshold)]) + if args.review_max_iterations is not None: + extra.extend(["--review-max-iterations", str(args.review_max_iterations)]) + if args.verbose: + extra.append("--verbose") + if args.no_trajectory: + extra.append("--no-trajectory") + elif stage.name == "feature_refactor": + if args.max_iter_refactor is not None: + extra.extend(["--max-iterations", str(args.max_iter_refactor)]) + if args.verbose: + extra.extend(["--log-level", "DEBUG"]) + if args.no_trajectory: + extra.append("--no-trajectory") + return extra + + +def _debug_args_for(stage: Stage) -> list[str]: + if stage.name == "feature_build": + return ["--verbose"] + if stage.name == "feature_refactor": + return ["--log-level", "DEBUG"] + return [] + + +def _target_output_for(stage: Stage) -> Optional[Path]: + return { + "feature_build": FEATURE_BUILD_FILE, + "feature_refactor": FEATURE_TREE_FILE, + }.get(stage.name) + + +def _should_reset_output(state: StageState) -> bool: + if not state.will_run or _target_output_for(state.stage) is None: + return False + return state.reason in _RESET_REASONS or state.type not in {"init", "update"} + + +def _reset_output_if_needed(state: StageState) -> None: + target = _target_output_for(state.stage) + if target is not None and _should_reset_output(state): + target.unlink(missing_ok=True) + + +def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="feature_construct.py", + description="Run the Phase 1 feature construction pipeline with automatic resume.", + ) + parser.add_argument("--check-only", action="store_true", help="Probe all stages and exit.") + parser.add_argument("--json", action="store_true", help="With --check-only, emit JSON.") + parser.add_argument("--force", action="store_true", help="Rebuild all Phase 1 stages.") + parser.add_argument("--dry-run", action="store_true", help="Print commands without executing them.") + parser.add_argument("--verbose", action="store_true", help="Forward native verbose logging flags.") + parser.add_argument("--no-trajectory", action="store_true", help="Disable trajectory recording where supported.") + parser.add_argument( + "--max-iter-refactor", + type=int, + default=None, + metavar="N", + help="Override feature_refactor.py --max-iterations.", + ) + parser.add_argument( + "--review-threshold", + type=float, + default=None, + metavar="N", + help="Forward to feature_build.py --review-threshold.", + ) + parser.add_argument( + "--review-max-iterations", + type=int, + default=None, + metavar="N", + help="Forward to feature_build.py --review-max-iterations.", + ) + return parser.parse_args(argv) + + +def _install_sigint_handler() -> None: + def _handle(signum: int, frame: Any) -> None: + print("\n[feature_construct] interrupted — rerun `rpgkit script feature_construct.py` to resume.") + sys.exit(130) + + signal.signal(signal.SIGINT, _handle) + + +def _print_failure_hint(stage: Stage, rc: int, *, phase: str) -> None: + debug_args = _debug_args_for(stage) + debug_cmd = " ".join(["rpgkit", "script", stage.build_script, *debug_args]) + print(file=sys.stderr) + print(f"X {stage.name} {phase} failed (exit {rc})", file=sys.stderr) + print(" Resume : rpgkit script feature_construct.py", file=sys.stderr) + print(f" Debug : {debug_cmd}", file=sys.stderr) + print(" Status : rpgkit script feature_construct.py --check-only", file=sys.stderr) + + +def main(argv: Optional[list[str]] = None) -> int: + args = _parse_args(argv) + _install_sigint_handler() + invoker = _resolve_invoker() + + states = probe() + decide(states, force=args.force) + + if args.check_only: + if args.json: + _emit_check_only_json(states) + else: + _print_probe_summary(states) + return 0 + + runnable = [state for state in states if state.will_run] + if not runnable: + print("All 3 Phase 1 feature construction stages are already complete — nothing to do.") + print("Use `rpgkit script feature_construct.py --force` to rebuild from scratch.") + print("Next: `/rpgkit.plan` to build the RPG planning pipeline.") + return 0 + + print(f"Feature construction pipeline: {len(runnable)} of {len(states)} stages to run.") + print(_format_table(states)) + print() + + if args.dry_run: + for state in runnable: + cmd = _script_argv(invoker, state.stage.build_script) + cmd += _build_args_for(state.stage, args) + print("DRY-RUN >", " ".join(cmd)) + return 0 + + started = time.monotonic() + for state in states: + if not state.will_run: + print(f"skip {state.stage.name:<16} ({state.reason})") + continue + + stage_started = time.monotonic() + print(f"run {state.stage.name:<16} {state.stage.build_script} ...") + _reset_output_if_needed(state) + rc = _run_stage(invoker, state.stage.build_script, _build_args_for(state.stage, args)) + if rc != 0: + _print_failure_hint(state.stage, rc, phase="build") + return rc + + verify = _check_stage(state.stage) + if verify.type != "update": + print( + f" verification failed: {verify.type} — {verify.message}", + file=sys.stderr, + ) + _print_failure_hint(state.stage, 1, phase="check") + return 1 + + elapsed = time.monotonic() - stage_started + print(f"done {state.stage.name:<16} in {elapsed:.1f}s") + + total_elapsed = time.monotonic() - started + print() + print(f"Feature construct complete in {total_elapsed:.1f}s.") + print("Next: `/rpgkit.plan` to build the RPG planning pipeline.") + print("Optional: expand features, or run `/rpgkit.feature_edit ` for small tree adjustments.") + print("Granular debug commands remain available: `/rpgkit.feature_spec`, `/rpgkit.feature_build`, `/rpgkit.feature_refactor`.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/RPG-Kit/scripts/plan.py b/RPG-Kit/scripts/plan.py new file mode 100644 index 0000000..0c54365 --- /dev/null +++ b/RPG-Kit/scripts/plan.py @@ -0,0 +1,539 @@ +#!/usr/bin/env python3 +"""Plan Orchestrator Script. + +Run the full RPG planning pipeline in one shot, replacing the +five sequential slash-commands ``/rpgkit.build_skeleton`` → +``/rpgkit.build_data_flow`` → ``/rpgkit.design_base_classes`` → +``/rpgkit.design_interfaces`` → ``/rpgkit.plan_tasks``. + +Design contract +--------------- + +This script is intentionally non-interactive. All user-facing +"continue / restart / exit" decisions belong to the slash-command +template (``templates/commands/plan.md``); this script only +implements the three execution modes the template chooses from: + +* ``--check-only [--json]`` — probe every stage's ``check_*.py`` + script and print a progress report, then exit 0. This is how + the template inspects the workspace before prompting the user. + +* (default) — *resume mode*: skip stages whose check returns + ``type == "update"``; run every other stage in dependency order. + Once any stage gets (re)built, all downstream stages are forced + to rebuild too, so up- and down-stream artifacts never drift + apart. + +* ``--force`` — discard the current progress and rebuild all five + stages from scratch. + +Sub-scripts are invoked via ``rpgkit script `` when the +``rpgkit`` CLI is on ``$PATH`` (so each stage gets its own +``logs/.log`` and inner-git snapshot, courtesy of the +dispatcher). When ``rpgkit`` is missing, the script falls back +to a direct ``python `` invocation. + +Exit codes +---------- + +* 0 — pipeline finished successfully (or nothing to do) +* 2 — argument error +* 130 — interrupted with Ctrl-C +* N — exit code of the first failing sub-stage (passed through) +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import signal +import subprocess +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +# Sub-scripts live in the same directory as this file (bundled under +# rpgkit_cli/core_pack/scripts/ in the installed wheel). +_SCRIPTS_DIR = Path(__file__).resolve().parent + + +# --------------------------------------------------------------------------- +# Stage table — single source of truth for the pipeline. +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Stage: + """One step of the planning pipeline.""" + + name: str # short id used by the user and the template + build_script: str # the .py runner under scripts/ + check_script: str # the .py probe under scripts/ + max_iter_flag: Optional[str] # CLI flag used by the build script, if any + + +STAGES: tuple[Stage, ...] = ( + Stage( + name="skeleton", + build_script="build_skeleton.py", + check_script="check_skeleton.py", + max_iter_flag="--max-iterations", + ), + Stage( + name="data_flow", + build_script="build_data_flow.py", + check_script="check_data_flow.py", + max_iter_flag="--max-iterations", + ), + Stage( + name="base_classes", + build_script="design_base_classes.py", + check_script="check_base_classes.py", + max_iter_flag="--max-iterations", + ), + Stage( + name="interfaces", + build_script="design_interfaces.py", + check_script="check_interfaces.py", + # design_interfaces uses a different flag name than the others. + max_iter_flag="--max-file-iterations", + ), + Stage( + name="tasks", + build_script="plan_tasks.py", + check_script="check_tasks.py", + max_iter_flag=None, # plan_tasks.py takes no iteration count. + ), +) + +# Post-pipeline helper scripts. Always run on a successful pipeline +# so the user gets an up-to-date summary + visualization. +POST_STEPS: tuple[str, ...] = ( + "summary_skeleton.py", + "generate_viz.py", +) + + +# --------------------------------------------------------------------------- +# Subprocess helpers. +# --------------------------------------------------------------------------- + +def _resolve_invoker() -> list[str]: + """Return the argv prefix used to invoke a sub-script. + + Prefer the ``rpgkit`` CLI so the dispatcher tees each stage's + output to ``~/.rpgkit/workspaces//logs/.log`` and + snapshots the inner git repo automatically. Fall back to a + direct python invocation when ``rpgkit`` is not on ``$PATH``. + """ + rpgkit = shutil.which("rpgkit") + if rpgkit: + return [rpgkit, "script"] + return [sys.executable] # script path appended by caller + + +def _script_argv(invoker: list[str], script_name: str) -> list[str]: + """Build the argv needed to invoke ``script_name`` via ``invoker``.""" + if Path(invoker[0]).stem == "rpgkit": + return [*invoker, script_name] + return [*invoker, str(_SCRIPTS_DIR / script_name)] + + +def _run_check(invoker: list[str], script_name: str) -> dict[str, Any]: + """Run a check_*.py script and parse its JSON stdout. + + The check scripts print exactly one JSON object on stdout when + invoked with ``--json``. We capture it without printing to the + parent terminal so the user is not flooded by 5 raw JSON blobs + during probing. ``--json`` is the unified contract across all + ``check_*.py`` scripts; ``check_skeleton.py`` accepts it as a + no-op for compatibility. + """ + argv = [*_script_argv(invoker, script_name), "--json"] + try: + proc = subprocess.run( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + except FileNotFoundError as exc: + return {"type": "error", "message": f"cannot invoke {argv[0]}: {exc}"} + + text = (proc.stdout or b"").decode("utf-8", errors="replace").strip() + if not text: + return { + "type": "error", + "message": f"{script_name} produced no output (exit {proc.returncode})", + } + try: + return json.loads(text) + except json.JSONDecodeError: + # Some checks may emit human-readable lines before the JSON + # object; take the last brace-balanced block. + last_obj = _extract_last_json_object(text) + if last_obj is not None: + return last_obj + return { + "type": "error", + "message": f"{script_name} returned non-JSON output", + } + + +def _extract_last_json_object(text: str) -> Optional[dict[str, Any]]: + """Best-effort: pull the last ``{...}`` block out of ``text``.""" + depth = 0 + start = -1 + last: Optional[str] = None + for i, ch in enumerate(text): + if ch == "{": + if depth == 0: + start = i + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0 and start >= 0: + last = text[start : i + 1] + if last is None: + return None + try: + obj = json.loads(last) + return obj if isinstance(obj, dict) else None + except json.JSONDecodeError: + return None + + +def _run_stage(invoker: list[str], script_name: str, extra: list[str]) -> int: + """Run a build_*/design_* script and stream its output live.""" + argv = [*_script_argv(invoker, script_name), *extra] + proc = subprocess.run(argv, check=False) + return proc.returncode + + +# --------------------------------------------------------------------------- +# Progress probing and decision logic. +# --------------------------------------------------------------------------- + +@dataclass +class StageState: + stage: Stage + type: str = "error" # init | update | warning | error + message: str = "" + done: bool = False + will_run: bool = False + reason: str = "" + raw: dict[str, Any] = field(default_factory=dict) + + +def probe(invoker: list[str]) -> list[StageState]: + """Run every check_*.py and return a parallel list of states.""" + states: list[StageState] = [] + for stage in STAGES: + result = _run_check(invoker, stage.check_script) + type_ = str(result.get("type", "error")) + states.append( + StageState( + stage=stage, + type=type_, + message=str(result.get("message", "")), + done=(type_ == "update"), + raw=result, + ) + ) + return states + + +def decide(states: list[StageState], force: bool) -> None: + """Mark each state's ``will_run`` / ``reason`` in place. + + Rule: any stage with ``type != "update"`` runs. Once any + stage runs, *all* downstream stages run too (cascade), so + derived artifacts never get out of sync with regenerated + upstream ones. ``--force`` flips every stage to ``will_run``. + """ + cascade = False + for state in states: + if force: + state.will_run = True + state.reason = "forced" + continue + if cascade: + state.will_run = True + state.reason = "upstream rebuilt" + continue + if state.type == "update": + state.will_run = False + state.reason = "up-to-date" + else: + state.will_run = True + state.reason = f"type={state.type}" + cascade = True + + +# --------------------------------------------------------------------------- +# Pretty-printing. +# --------------------------------------------------------------------------- + +_GLYPH = {"update": "✓", "init": "·", "warning": "!", "error": "✗"} + + +def _format_table(states: list[StageState]) -> str: + rows = ["Stage Type Done Action"] + rows.append("-" * 50) + for s in states: + glyph = _GLYPH.get(s.type, "?") + action = "run" if s.will_run else "skip" + rows.append( + f"{s.stage.name:<14} {glyph} {s.type:<7} " + f"{'yes' if s.done else 'no ':<3} {action}" + ) + return "\n".join(rows) + + +def _print_probe_summary(states: list[StageState]) -> None: + done = sum(1 for s in states if s.done) + total = len(states) + first_pending = next((s.stage.name for s in states if not s.done), None) + print(f"Planning progress: {done}/{total} stages complete.") + if first_pending: + print(f"Next pending stage: {first_pending}") + else: + print("All stages are up-to-date.") + print() + print(_format_table(states)) + + +def _emit_check_only_json(states: list[StageState]) -> None: + done = sum(1 for s in states if s.done) + total = len(states) + next_pending = next((s.stage.name for s in states if not s.done), None) + payload = { + "total": total, + "done": done, + "next": next_pending, + "stages": [ + { + "name": s.stage.name, + "type": s.type, + "message": s.message, + "done": s.done, + } + for s in states + ], + } + print(json.dumps(payload, indent=2)) + + +# --------------------------------------------------------------------------- +# Build-args assembly. +# --------------------------------------------------------------------------- + +def _build_args_for(stage: Stage, args: argparse.Namespace) -> list[str]: + """Collect CLI args to forward to ``stage.build_script``.""" + extra: list[str] = [] + if stage.max_iter_flag is not None: + value = getattr(args, f"max_iter_{stage.name}", None) + if value is not None: + extra.extend([stage.max_iter_flag, str(value)]) + if args.verbose: + extra.append("--verbose") + if args.no_trajectory: + extra.append("--no-trajectory") + return extra + + +# --------------------------------------------------------------------------- +# Entry point. +# --------------------------------------------------------------------------- + +def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="plan.py", + description=( + "Run the full RPG planning pipeline (skeleton → data_flow → " + "base_classes → interfaces → tasks) with automatic resume." + ), + ) + p.add_argument( + "--check-only", + action="store_true", + help="Probe every stage and print progress, then exit. No build runs.", + ) + p.add_argument( + "--json", + action="store_true", + help="With --check-only, emit a machine-readable JSON progress report.", + ) + p.add_argument( + "--force", + action="store_true", + help="Ignore current progress and rebuild every stage from scratch.", + ) + p.add_argument( + "--dry-run", + action="store_true", + help="Print the commands that would run without executing them.", + ) + p.add_argument( + "--verbose", + action="store_true", + help="Forward --verbose to every sub-script.", + ) + p.add_argument( + "--no-trajectory", + action="store_true", + help="Forward --no-trajectory to every sub-script that supports it.", + ) + # Per-stage iteration overrides (only the four stages that take one). + for stage in STAGES: + if stage.max_iter_flag is None: + continue + p.add_argument( + f"--max-iter-{stage.name.replace('_', '-')}", + dest=f"max_iter_{stage.name}", + type=int, + default=None, + metavar="N", + help=f"Override iteration count for the '{stage.name}' stage.", + ) + return p.parse_args(argv) + + +def _install_sigint_handler() -> None: + def _handle(signum: int, frame: Any) -> None: # noqa: ARG001 + print("\n[plan] interrupted — rerun `rpgkit script plan.py` to resume.") + sys.exit(130) + + signal.signal(signal.SIGINT, _handle) + + +def main(argv: Optional[list[str]] = None) -> int: + args = _parse_args(argv) + _install_sigint_handler() + invoker = _resolve_invoker() + + # --- Phase 1: probe ---------------------------------------------------- + states = probe(invoker) + decide(states, force=args.force) + + if args.check_only: + if args.json: + _emit_check_only_json(states) + else: + _print_probe_summary(states) + return 0 + + # --- Phase 1b: prerequisite check -------------------------------------- + # If the very first stage cannot even start (its input is missing or + # invalid), abort cleanly so the user gets a helpful pointer instead + # of a confusing failure from the build script itself. ``--dry-run`` + # bypasses this so users can preview commands without an initialised + # workspace. + head = states[0] + if head.type == "error" and not args.dry_run: + print( + f"Cannot start the planning pipeline: {head.message}", + file=sys.stderr, + ) + print( + "Run `/rpgkit.feature_refactor` first to produce " + "`feature_tree.json`, then re-run `/rpgkit.plan`.", + file=sys.stderr, + ) + return 2 + + # --- Phase 2: short-circuit when nothing to do ------------------------- + runnable = [s for s in states if s.will_run] + if not runnable: + print("All 5 planning stages are already complete — nothing to do.") + print("Use `rpgkit script plan.py --force` to rebuild from scratch.") + return 0 + + # --- Phase 3: announce plan ------------------------------------------- + print(f"Planning pipeline: {len(runnable)} of {len(states)} stages to run.") + print(_format_table(states)) + print() + + if args.dry_run: + for s in runnable: + cmd = _script_argv(invoker, s.stage.build_script) + cmd += _build_args_for(s.stage, args) + print("DRY-RUN ▸", " ".join(cmd)) + for post in POST_STEPS: + print("DRY-RUN ▸", " ".join(_script_argv(invoker, post))) + return 0 + + # --- Phase 4: execute -------------------------------------------------- + started = time.monotonic() + for s in states: + if not s.will_run: + print(f"⏭ {s.stage.name:<14} skip ({s.reason})") + continue + + stage_started = time.monotonic() + print(f"▶ {s.stage.name:<14} running {s.stage.build_script} ...") + build_extra = _build_args_for(s.stage, args) + rc = _run_stage(invoker, s.stage.build_script, build_extra) + if rc != 0: + _print_failure_hint(s.stage, rc, phase="build") + return rc + + # Re-run the check to confirm the artifact came out valid. Parse + # its JSON quietly; surface details only when the verification + # fails, otherwise the user would see a JSON dump after every + # stage. + verify = _run_check(invoker, s.stage.check_script) + if verify.get("type") != "update": + print( + f" verification failed: {verify.get('type', 'error')} — " + f"{verify.get('message', 'no message')}", + file=sys.stderr, + ) + for err in verify.get("validation_errors", [])[:5]: + print(f" - {err}", file=sys.stderr) + _print_failure_hint(s.stage, 1, phase="check") + return 1 + + elapsed = time.monotonic() - stage_started + print(f"✓ {s.stage.name:<14} done in {elapsed:.1f}s") + + # --- Phase 5: post-pipeline helpers ----------------------------------- + print() + print("Running post-pipeline helpers ...") + for post in POST_STEPS: + print(f"▶ {post}") + rc = _run_stage(invoker, post, []) + if rc != 0: + print(f" warning: {post} exited with {rc} (continuing)") + + total_elapsed = time.monotonic() - started + print() + print(f"Plan complete in {total_elapsed:.1f}s.") + print("Next: `/rpgkit.code_gen` to generate source code.") + print("Graph: see the 'Writing visualization to:' line above for the generated HTML path.") + + +def _print_failure_hint(stage: Stage, rc: int, *, phase: str) -> None: + """Print recovery hints to stderr after a stage fails. + + ``phase`` is ``"build"`` or ``"check"``; the debug command points at + the script that actually failed so the user can reproduce. + """ + debug_script = stage.build_script if phase == "build" else stage.check_script + print() + print(f"✗ {stage.name} {phase} failed (exit {rc})", file=sys.stderr) + print(" Resume : rpgkit script plan.py", file=sys.stderr) + print( + f" Debug : rpgkit script {debug_script} --verbose", + file=sys.stderr, + ) + print( + " Status : rpgkit script plan.py --check-only", + file=sys.stderr, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/RPG-Kit/templates/commands/build_data_flow.md b/RPG-Kit/templates/commands/build_data_flow.md index aca1014..6f94563 100644 --- a/RPG-Kit/templates/commands/build_data_flow.md +++ b/RPG-Kit/templates/commands/build_data_flow.md @@ -24,7 +24,7 @@ Unless it is explicitly empty, you may assume it is always available as `$ARGUME Run the script `rpgkit script check_data_flow.py` to verify the current state. -1. Inspect the `state` field in the output: +1. Inspect the `type` field in the output: * `error` → Display the error message and stop. Instruct user to fix the error or regenerate. Terminate this command. * `init` → Proceed to Step 2. diff --git a/RPG-Kit/templates/commands/design_base_classes.md b/RPG-Kit/templates/commands/design_base_classes.md index 4bfbafb..74f2d88 100644 --- a/RPG-Kit/templates/commands/design_base_classes.md +++ b/RPG-Kit/templates/commands/design_base_classes.md @@ -22,7 +22,7 @@ Unless it is explicitly empty, you may assume it is always available as `$ARGUME Run the script `rpgkit script check_base_classes.py` to verify the current state. -1. Inspect the `state` field in the output: +1. Inspect the `type` field in the output: * `error` → Display the error message and stop. Instruct user to fix the error or regenerate. Terminate this command. * `init` → Proceed to Step 2. diff --git a/RPG-Kit/templates/commands/feature_construct.md b/RPG-Kit/templates/commands/feature_construct.md new file mode 100644 index 0000000..508d057 --- /dev/null +++ b/RPG-Kit/templates/commands/feature_construct.md @@ -0,0 +1,234 @@ +--- +description: Run Phase 1 feature specification, feature building, and feature refactoring in one step with automatic resume +name: rpgkit.feature_construct +--- + +## User Input + +```text +$ARGUMENTS +``` + +This command consolidates `/rpgkit.feature_spec`, `/rpgkit.feature_build`, and `/rpgkit.feature_refactor` into the recommended one-step Phase 1 flow. The granular commands remain available for debugging, surgical reruns, and single-stage recovery. + +## Argument Parsing + +Supported facade options: + +- `--check-only` +- `--json` +- `--force` +- `--dry-run` +- `--verbose` +- `--no-trajectory` +- `--max-iter-refactor N` +- `--review-threshold N` +- `--review-max-iterations N` + +If options and requirement text are both present, split them with `--`: + +```text +/rpgkit.feature_construct --review-threshold 99 -- Build a CLI for managing containers +``` + +If no options are present, treat the whole argument string as requirement text: + +```text +/rpgkit.feature_construct Build a CLI for managing containers +``` + +Forward only the supported options to `rpgkit script feature_construct.py`; do not forward requirement text to the script helper. + +If the user invoked `/rpgkit.feature_construct --check-only`, run: + +```bash +rpgkit script feature_construct.py --check-only +``` + +If the user invoked `/rpgkit.feature_construct --check-only --json`, run: + +```bash +rpgkit script feature_construct.py --check-only --json +``` + +In both check-only cases, show the script status output and stop without asking for requirements, inspecting `docs/` for generation, or running the pipeline. + +## Outline + +> [!WARNING] +> A full Phase 1 run can take from a few minutes to over an hour depending on project size. Do not interrupt it; if you must, re-run this command and it will resume from the first incomplete stage. + +### Step 1: Probe progress + +Run the orchestrator in probe mode and capture JSON: + +```bash +rpgkit script feature_construct.py --check-only --json +``` + +Parse these fields: + +- `total` — total stages, always 3 +- `done` — count of stages whose `type` is `update` +- `next` — first incomplete stage, or `null` if all done +- `stages[*].name`, `stages[*].done`, `stages[*].will_run`, `stages[*].reason` + +### Step 2: Determine requirement source + +Requirement source is needed only when `feature_spec` is not complete or when the user chooses to overwrite/restart from the beginning. + +Use this priority order: + +1. **Requirement text after the command** — use it directly. +2. **Usable `docs/*.md` files** — if no requirement text was provided and Markdown files exist under `docs/`, use them automatically as the source. Do not ask the old `/rpgkit.feature_spec` confirmation prompt. +3. **Inline requirement prompt** — if there is neither requirement text nor usable `docs/*.md`, pause in this same command flow and ask the user to provide requirements. After the user supplies them, continue; do not ask them to rerun the slash command. + +If the user supplied new requirement text while any Phase 1 artifact already exists and `--force` was not supplied, ask one real overwrite decision before changing artifacts: + +```text +Phase 1 artifacts already exist, and new requirements were provided. + +What would you like to do? + [R] Restart — regenerate Phase 1 from the new requirements + [E] Exit — keep existing artifacts unchanged +``` + +`R` continues with `--force`; `E` terminates the command. + +### Step 3: Create or refresh feature specification artifacts when needed + +If `feature_spec` is incomplete, or if the user chose restart/overwrite from Step 2, generate the feature specification artifacts using the existing `/rpgkit.feature_spec` workflow, but without its avoidable docs confirmation prompt: + +- For direct requirement text, create `.rpgkit/data/feature_spec/evidence/user_input.md`, `.rpgkit/data/feature_spec/feature_spec.md`, and `.rpgkit/data/feature_spec/features/FT-*.md` following the rules in `/rpgkit.feature_spec`. +- For `docs/*.md`, process each document one by one into evidence, then generate the main spec and feature-domain files following the rules in `/rpgkit.feature_spec`. +- On restart, overwrite, or `--force` from the beginning, replace the selected input source's feature-spec working tree contents before conversion. Remove stale `.rpgkit/data/feature_spec/evidence/*.md` files and stale `.rpgkit/data/feature_spec/features/FT-*.md` files that are no longer part of the regenerated spec; do not leave markdown artifacts from a previous source in place. +- Preserve the existing quality requirements: English output, evidence line numbers, feature IDs, project type metadata, and JSON conversion readiness. +- Run `rpgkit script feature_spec_to_json.py` only after the markdown working tree reflects the selected source and stale generated markdown has been removed. + +Do not prompt for review thresholds, review iteration counts, or refactor iteration counts in the default path. Use existing script defaults unless the user supplied explicit facade options. + +### Step 4: Run the one-step Phase 1 helper + +Choose exactly one execution mode: + +**Case A — all three stages already complete (`done == total`)** + +Display: + +```text +All 3 Phase 1 stages are already complete: + ✓ feature_spec + ✓ feature_build + ✓ feature_refactor + +What would you like to do? + [X] Expand features — suggest expansion directions, expand selected ones, then rerun refactor + [O] Overwrite — regenerate Phase 1 from scratch + [E] Exit — keep existing artifacts and proceed to /rpgkit.plan +``` + +- `X` → go to Step 6. +- `O` → ensure a requirement source exists using Step 2, then run `rpgkit script feature_construct.py --force `. +- `E` → terminate after showing the completion guidance in Step 7. + +**Case B — fresh or incomplete workspace (`done < total`)** + +If `done == 0`, do not prompt. Briefly inform the user: + +```text +Starting Phase 1 feature construction (3 stages). This may take a while. +``` + +If `0 < done < total`, do not ask for stage parameters. Continue automatically from `next` unless there is a real overwrite decision from Step 2. + +Run: + +```bash +rpgkit script feature_construct.py +``` + +If restart was chosen or `--force` was supplied, run: + +```bash +rpgkit script feature_construct.py --force +``` + +If `--dry-run` was supplied, stream the dry-run command list and stop without modifying artifacts. + +### Step 5: Stream output and handle failures + +Stream stdout/stderr from `feature_construct.py` as-is. The helper prints one progress line per stage and validates each generated artifact after the stage runs. + +If the helper exits non-zero, surface its recovery hints verbatim. Typical recovery commands: + +```bash +rpgkit script feature_construct.py --check-only +rpgkit script feature_construct.py +rpgkit script feature_build.py --verbose +rpgkit script feature_refactor.py --log-level DEBUG +``` + +### Step 6: Optional feature expansion + +After normal completion, and also from the already-complete case, offer optional expansion: + +```text +Feature construction is complete. + +Would you like to expand the feature tree beyond the current specification? + [Y] Yes — suggest expansion directions + [N] No — finish here +``` + +If `Y`: + +1. Run direction suggestion: + + ```bash + rpgkit script feature_build.py --mode suggest-directions + ``` + + Include `--verbose` or `--no-trajectory` only if those facade options were supplied. + +2. Parse the JSON output and show directions as a numbered Markdown table. + +3. Ask for comma-separated direction numbers or `N` to finish. Normalize numeric input before passing it to the script. + +4. Run directed expansion: + + ```bash + rpgkit script feature_build.py --mode step2 --direction "" + ``` + + Forward `--review-max-iterations`, `--verbose`, and `--no-trajectory` if supplied. Do not forward `--review-threshold` to `step2`; that mode uses the existing lightweight review flow. + +5. Immediately rerun refactor so `.rpgkit/data/feature_tree.json` reflects the expanded `.rpgkit/data/feature_build.json`: + + ```bash + rpgkit script feature_refactor.py + ``` + + Forward `--max-iterations ` when the facade option was `--max-iter-refactor N`, `--log-level DEBUG` when `--verbose` was supplied, and `--no-trajectory` when supplied. + +6. Ask whether the user wants another expansion round. If yes, repeat Step 6 from direction suggestion; if no, continue to Step 7. + +### Step 7: Completion message + +On success or when the all-complete case exits without changes, tell the user: + +```text +Feature construct complete. + +Default next step: + /rpgkit.plan — run the full Phase 2 RPG planning pipeline + +Optional refinements: + Expand features — rerun this command and choose expansion, or answer Y when prompted + /rpgkit.feature_edit — adjust the final feature tree if it is unsatisfactory + +Granular/debug commands remain available: + /rpgkit.feature_spec, /rpgkit.feature_build, /rpgkit.feature_refactor + +Phase 2 granular fallback: + /rpgkit.build_skeleton — debug/surgical fallback only; /rpgkit.plan is the default next step +``` diff --git a/RPG-Kit/templates/commands/plan.md b/RPG-Kit/templates/commands/plan.md new file mode 100644 index 0000000..45620b7 --- /dev/null +++ b/RPG-Kit/templates/commands/plan.md @@ -0,0 +1,150 @@ +--- +description: Run the full RPG planning pipeline (skeleton → data_flow → base_classes → interfaces → tasks) in one step with automatic resume +name: rpgkit.plan +--- + +## User Input + +```text +$ARGUMENTS +``` + +`$ARGUMENTS` is forwarded verbatim to `rpgkit script plan.py` (for +example, `--verbose`, `--max-iter-skeleton 15`, or `--force`). +If empty, proceed with default behavior. + +## **Outline** + +This command consolidates the five planning sub-commands +(`/rpgkit.build_skeleton`, `/rpgkit.build_data_flow`, +`/rpgkit.design_base_classes`, `/rpgkit.design_interfaces`, +`/rpgkit.plan_tasks`) into a single non-interactive run with +automatic resume. + +> [!WARNING] +> A full pipeline run can take from a few minutes to over an hour +> depending on project size. Set your terminal timeout to at least +> **240 minutes** before running. Do **not** interrupt it; if you +> must, re-run this command and it will resume from where it stopped. + +### Step 1: Probe progress + +Run the orchestrator in probe mode and capture the JSON report: + +```bash +rpgkit script plan.py --check-only --json +``` + +Parse the JSON. The fields you need: + +* `total` — total number of stages (always 5) +* `done` — count of stages whose `type` is `update` +* `next` — name of the first not-done stage (or `null` if all done) +* `stages[*].name`, `stages[*].done` + +### Step 2: One decision (the only prompt of this command) + +Choose **exactly one** case based on `done` vs `total`: + +**Case A — Everything already done (`done == total`):** + +Display this prompt and wait for the user's choice: + +```text +All 5 planning stages are already complete: + ✓ skeleton + ✓ data_flow + ✓ base_classes + ✓ interfaces + ✓ tasks + +What would you like to do? + [O] Overwrite — regenerate everything from scratch + [E] Exit — keep existing artifacts and proceed to /rpgkit.code_gen +``` + +* `O` → execute: `rpgkit script plan.py --force $ARGUMENTS` +* `E` → terminate this command; remind the user that `/rpgkit.code_gen` + is the next step. + +**Case B — Fresh workspace (`done == 0`):** + +Do **not** prompt. Briefly inform the user and proceed: + +```text +Starting the full planning pipeline (5 stages). This may take a while. +``` + +Then execute: `rpgkit script plan.py $ARGUMENTS` + +**Case C — Partial progress (`0 < done < total`):** + +Display this prompt, with each stage marked using its real status +from `stages[*].done` (`✓` for done, `▸` for the first not-done one, +`·` for the rest): + +```text +Planning is partially complete: / stages done. + skeleton + data_flow + base_classes + interfaces + tasks + +Last completed stage: +Stopped at: + +What would you like to do? + [C] Continue — resume from `` and finish the pipeline + [R] Restart — discard progress and regenerate everything + [E] Exit — do nothing +``` + +* `C` → execute: `rpgkit script plan.py $ARGUMENTS` +* `R` → execute: `rpgkit script plan.py --force $ARGUMENTS` +* `E` → terminate this command. + +### Step 3: Stream the orchestrator's output + +When you execute the orchestrator (cases A → O, B, C → C/R above), +stream its stdout/stderr to the user as-is. The orchestrator already +prints one progress line per stage and a final summary; do not add +your own commentary on top of every line. + +### Step 4: On failure + +If the orchestrator exits non-zero, it has already printed a +`✗ ... failed` line plus three recovery hints. Surface those +hints verbatim. The most common follow-ups are: + +```bash +# Re-check progress (no side effects). +rpgkit script plan.py --check-only + +# Resume from where it failed (default behavior). +rpgkit script plan.py + +# Debug a single stage interactively. +rpgkit script .py --verbose +``` + +### Step 5: On success + +Tell the user: + +```text +Planning pipeline complete. + +Next: + /rpgkit.code_gen — generate source code from the plan + +Inspect: + ~/.rpgkit/workspaces//data/data_flow_viz.html — interactive DAG visualization + rpgkit script plan.py --check-only — re-print the progress table + +For surgical adjustments to individual stages, the granular +slash-commands are still available: + /rpgkit.build_skeleton, /rpgkit.build_data_flow, + /rpgkit.design_base_classes, /rpgkit.design_interfaces, + /rpgkit.plan_tasks +``` diff --git a/RPG-Kit/tests/test_feature_construct_orchestrator.py b/RPG-Kit/tests/test_feature_construct_orchestrator.py new file mode 100644 index 0000000..8bfe883 --- /dev/null +++ b/RPG-Kit/tests/test_feature_construct_orchestrator.py @@ -0,0 +1,365 @@ +"""Unit tests for the Phase 1 feature construction orchestrator.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[1] +_SCRIPTS = _REPO / "scripts" + +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + +_SPEC = importlib.util.spec_from_file_location( + "feature_construct_orchestrator", + _SCRIPTS / "feature_construct.py", +) +assert _SPEC is not None and _SPEC.loader is not None +feature_construct = importlib.util.module_from_spec(_SPEC) +sys.modules["feature_construct_orchestrator"] = feature_construct +_SPEC.loader.exec_module(feature_construct) + + +@pytest.fixture +def artifact_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Path]: + paths = { + "feature_spec": tmp_path / "feature_spec.json", + "feature_build": tmp_path / "feature_build.json", + "feature_refactor": tmp_path / "feature_tree.json", + } + monkeypatch.setattr(feature_construct, "FEATURE_SPEC_FILE", paths["feature_spec"]) + monkeypatch.setattr(feature_construct, "FEATURE_BUILD_FILE", paths["feature_build"]) + monkeypatch.setattr(feature_construct, "FEATURE_TREE_FILE", paths["feature_refactor"]) + return paths + + +def _write_json(path: Path, data: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data), encoding="utf-8") + + +def _write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _valid_feature_spec() -> dict[str, object]: + return { + "meta": {"generated_at": "2026-05-25", "project_types": ["CLI"]}, + "repository_name": "sample-cli", + "repository_purpose": "Build a sample CLI.", + "background_and_overview": [{"id": "BG-001", "description": "Users need a CLI."}], + "functional_requirements": [{"id": "FT-001", "name": "CLI", "children": []}], + "non_functional_requirements": [{"id": "NFR-001", "description": "Fast startup."}], + } + + +def _states(types: list[str]) -> list["feature_construct.StageState"]: + assert len(types) == len(feature_construct.STAGES) + return [ + feature_construct.StageState(stage=stage, type=t, done=(t == "update")) + for stage, t in zip(feature_construct.STAGES, types) + ] + + +class TestStageRegistry: + def test_three_stages_in_canonical_order(self) -> None: + assert [stage.name for stage in feature_construct.STAGES] == [ + "feature_spec", + "feature_build", + "feature_refactor", + ] + + @pytest.mark.parametrize("stage", feature_construct.STAGES) + def test_every_stage_has_a_build_script(self, stage: "feature_construct.Stage") -> None: + assert (_SCRIPTS / stage.build_script).is_file(), stage.build_script + + +class TestCompletionDetection: + def test_missing_artifacts_are_incomplete(self, artifact_paths: dict[str, Path]) -> None: + states = feature_construct.probe() + assert [state.type for state in states] == ["init", "init", "init"] + assert [state.done for state in states] == [False, False, False] + + def test_valid_artifacts_are_complete(self, artifact_paths: dict[str, Path]) -> None: + _write_json(artifact_paths["feature_spec"], _valid_feature_spec()) + _write_json(artifact_paths["feature_build"], {"feature_tree": {}}) + _write_json(artifact_paths["feature_refactor"], {"components": [{"name": "core"}]}) + + states = feature_construct.probe() + assert [state.type for state in states] == ["update", "update", "update"] + assert [state.done for state in states] == [True, True, True] + + def test_feature_spec_requires_downstream_fields(self, artifact_paths: dict[str, Path]) -> None: + spec = _valid_feature_spec() + spec.pop("functional_requirements") + _write_json(artifact_paths["feature_spec"], spec) + + state = feature_construct.probe()[0] + assert state.type == "warning" + assert state.done is False + assert "functional_requirements" in state.message + + def test_feature_refactor_requires_non_empty_components(self, artifact_paths: dict[str, Path]) -> None: + _write_json(artifact_paths["feature_refactor"], {"components": []}) + + state = feature_construct.probe()[2] + assert state.type == "warning" + assert state.done is False + assert "components" in state.message + + +class TestCheckOnlyJson: + def test_json_payload_reports_progress(self, artifact_paths: dict[str, Path], capsys: pytest.CaptureFixture[str]) -> None: + _write_json(artifact_paths["feature_spec"], _valid_feature_spec()) + _write_json(artifact_paths["feature_build"], {"feature_tree": {}}) + + rc = feature_construct.main(["--check-only", "--json"]) + captured = capsys.readouterr() + payload = json.loads(captured.out) + + assert rc == 0 + assert payload["total"] == 3 + assert payload["done"] == 2 + assert payload["completed"] == 2 + assert payload["next"] == "feature_refactor" + assert [stage["name"] for stage in payload["stages"]] == [ + "feature_spec", + "feature_build", + "feature_refactor", + ] + assert [stage["done"] for stage in payload["stages"]] == [True, True, False] + + +class TestExecutionReset: + def test_force_removes_stale_output_sensitive_artifacts_before_stage_invocation( + self, + artifact_paths: dict[str, Path], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _write_json(artifact_paths["feature_spec"], _valid_feature_spec()) + _write_json(artifact_paths["feature_build"], {"stale": "build"}) + _write_json(artifact_paths["feature_refactor"], {"components": [{"name": "stale"}]}) + calls: list[str] = [] + + def fake_run_stage(invoker: list[str], script_name: str, extra: list[str]) -> int: + calls.append(script_name) + if script_name == "feature_spec_to_json.py": + _write_json(artifact_paths["feature_spec"], _valid_feature_spec()) + elif script_name == "feature_build.py": + assert not artifact_paths["feature_build"].exists() + _write_json(artifact_paths["feature_build"], {"feature_tree": {"fresh": True}}) + elif script_name == "feature_refactor.py": + assert not artifact_paths["feature_refactor"].exists() + _write_json(artifact_paths["feature_refactor"], {"components": [{"name": "fresh"}]}) + return 0 + + monkeypatch.setattr(feature_construct, "_run_stage", fake_run_stage) + + rc = feature_construct.main(["--force"]) + + assert rc == 0 + assert calls == ["feature_spec_to_json.py", "feature_build.py", "feature_refactor.py"] + + def test_cascade_removes_stale_downstream_artifacts_before_stage_invocation( + self, + artifact_paths: dict[str, Path], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + spec = _valid_feature_spec() + spec.pop("repository_purpose") + _write_json(artifact_paths["feature_spec"], spec) + _write_json(artifact_paths["feature_build"], {"stale": "build"}) + _write_json(artifact_paths["feature_refactor"], {"components": [{"name": "stale"}]}) + calls: list[str] = [] + + def fake_run_stage(invoker: list[str], script_name: str, extra: list[str]) -> int: + calls.append(script_name) + if script_name == "feature_spec_to_json.py": + _write_json(artifact_paths["feature_spec"], _valid_feature_spec()) + elif script_name == "feature_build.py": + assert not artifact_paths["feature_build"].exists() + _write_json(artifact_paths["feature_build"], {"feature_tree": {"fresh": True}}) + elif script_name == "feature_refactor.py": + assert not artifact_paths["feature_refactor"].exists() + _write_json(artifact_paths["feature_refactor"], {"components": [{"name": "fresh"}]}) + return 0 + + monkeypatch.setattr(feature_construct, "_run_stage", fake_run_stage) + + rc = feature_construct.main([]) + + assert rc == 0 + assert calls == ["feature_spec_to_json.py", "feature_build.py", "feature_refactor.py"] + + def test_invalid_output_sensitive_artifact_is_removed_before_stage_invocation( + self, + artifact_paths: dict[str, Path], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _write_json(artifact_paths["feature_spec"], _valid_feature_spec()) + _write_text(artifact_paths["feature_build"], "{") + _write_json(artifact_paths["feature_refactor"], {"components": [{"name": "stale"}]}) + calls: list[str] = [] + + def fake_run_stage(invoker: list[str], script_name: str, extra: list[str]) -> int: + calls.append(script_name) + if script_name == "feature_build.py": + assert not artifact_paths["feature_build"].exists() + _write_json(artifact_paths["feature_build"], {"feature_tree": {"fresh": True}}) + elif script_name == "feature_refactor.py": + assert not artifact_paths["feature_refactor"].exists() + _write_json(artifact_paths["feature_refactor"], {"components": [{"name": "fresh"}]}) + return 0 + + monkeypatch.setattr(feature_construct, "_run_stage", fake_run_stage) + + rc = feature_construct.main([]) + + assert rc == 0 + assert calls == ["feature_build.py", "feature_refactor.py"] + + def test_all_up_to_date_skip_path_does_not_remove_artifacts( + self, + artifact_paths: dict[str, Path], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _write_json(artifact_paths["feature_spec"], _valid_feature_spec()) + _write_json(artifact_paths["feature_build"], {"feature_tree": {}}) + _write_json(artifact_paths["feature_refactor"], {"components": [{"name": "core"}]}) + + def fail_run_stage(invoker: list[str], script_name: str, extra: list[str]) -> int: + pytest.fail(f"unexpected stage run: {script_name}") + + monkeypatch.setattr(feature_construct, "_run_stage", fail_run_stage) + + rc = feature_construct.main([]) + + assert rc == 0 + assert artifact_paths["feature_build"].exists() + assert artifact_paths["feature_refactor"].exists() + + @pytest.mark.parametrize("argv", [["--check-only"], ["--check-only", "--json"]]) + def test_check_only_does_not_remove_artifacts_or_run_stages( + self, + argv: list[str], + artifact_paths: dict[str, Path], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _write_text(artifact_paths["feature_spec"], "{") + _write_json(artifact_paths["feature_build"], {"stale": "build"}) + _write_json(artifact_paths["feature_refactor"], {"components": [{"name": "stale"}]}) + + def fail_run_stage(invoker: list[str], script_name: str, extra: list[str]) -> int: + pytest.fail(f"unexpected stage run: {script_name}") + + monkeypatch.setattr(feature_construct, "_run_stage", fail_run_stage) + + rc = feature_construct.main(argv) + + assert rc == 0 + assert artifact_paths["feature_spec"].exists() + assert artifact_paths["feature_build"].exists() + assert artifact_paths["feature_refactor"].exists() + + def test_dry_run_does_not_remove_artifacts_or_run_stages( + self, + artifact_paths: dict[str, Path], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + _write_json(artifact_paths["feature_spec"], _valid_feature_spec()) + _write_text(artifact_paths["feature_build"], "{") + _write_json(artifact_paths["feature_refactor"], {"components": [{"name": "stale"}]}) + + def fail_run_stage(invoker: list[str], script_name: str, extra: list[str]) -> int: + pytest.fail(f"unexpected stage run: {script_name}") + + monkeypatch.setattr(feature_construct, "_run_stage", fail_run_stage) + + rc = feature_construct.main(["--dry-run"]) + captured = capsys.readouterr() + + assert rc == 0 + assert "DRY-RUN >" in captured.out + assert artifact_paths["feature_build"].exists() + assert artifact_paths["feature_refactor"].exists() + + +class TestDecideCascade: + def test_all_update_means_nothing_runs(self) -> None: + states = _states(["update", "update", "update"]) + feature_construct.decide(states, force=False) + assert [state.will_run for state in states] == [False, False, False] + + def test_fresh_workspace_runs_everything(self) -> None: + states = _states(["init", "init", "init"]) + feature_construct.decide(states, force=False) + assert [state.will_run for state in states] == [True, True, True] + + def test_partial_resume_runs_from_first_incomplete_stage(self) -> None: + states = _states(["update", "init", "update"]) + feature_construct.decide(states, force=False) + assert [state.will_run for state in states] == [False, True, True] + assert "upstream" in states[2].reason + + def test_upstream_warning_cascades_to_downstream_update(self) -> None: + states = _states(["warning", "update", "update"]) + feature_construct.decide(states, force=False) + assert [state.will_run for state in states] == [True, True, True] + assert "upstream" in states[1].reason + + def test_force_runs_everything(self) -> None: + states = _states(["update", "update", "update"]) + feature_construct.decide(states, force=True) + assert [state.will_run for state in states] == [True, True, True] + assert [state.reason for state in states] == ["forced", "forced", "forced"] + + +class TestBuildArgs: + def test_feature_build_forwards_review_options(self) -> None: + ns = feature_construct._parse_args([ + "--review-threshold", + "99", + "--review-max-iterations", + "4", + ]) + stage = next(stage for stage in feature_construct.STAGES if stage.name == "feature_build") + args = feature_construct._build_args_for(stage, ns) + + assert args[:2] == ["--mode", "step1"] + assert args[args.index("--review-threshold") + 1] == "99" + assert args[args.index("--review-max-iterations") + 1] == "4" + + def test_feature_refactor_maps_facade_iteration_flag(self) -> None: + ns = feature_construct._parse_args(["--max-iter-refactor", "7"]) + stage = next(stage for stage in feature_construct.STAGES if stage.name == "feature_refactor") + args = feature_construct._build_args_for(stage, ns) + + assert "--max-iterations" in args + assert args[args.index("--max-iterations") + 1] == "7" + assert "--max-iter-refactor" not in args + + def test_verbose_and_no_trajectory_use_native_stage_names(self) -> None: + ns = feature_construct._parse_args(["--verbose", "--no-trajectory"]) + build_stage = next(stage for stage in feature_construct.STAGES if stage.name == "feature_build") + refactor_stage = next(stage for stage in feature_construct.STAGES if stage.name == "feature_refactor") + + build_args = feature_construct._build_args_for(build_stage, ns) + refactor_args = feature_construct._build_args_for(refactor_stage, ns) + + assert "--verbose" in build_args + assert "--no-trajectory" in build_args + assert "--log-level" in refactor_args + assert refactor_args[refactor_args.index("--log-level") + 1] == "DEBUG" + assert "--no-trajectory" in refactor_args + + def test_feature_spec_receives_no_unsupported_options(self) -> None: + ns = feature_construct._parse_args(["--verbose", "--no-trajectory"]) + stage = next(stage for stage in feature_construct.STAGES if stage.name == "feature_spec") + assert feature_construct._build_args_for(stage, ns) == [] diff --git a/RPG-Kit/tests/test_plan_orchestrator.py b/RPG-Kit/tests/test_plan_orchestrator.py new file mode 100644 index 0000000..b60cce4 --- /dev/null +++ b/RPG-Kit/tests/test_plan_orchestrator.py @@ -0,0 +1,209 @@ +"""Unit tests for the planning orchestrator's pure logic. + +Covers the decision rules of ``scripts/plan.py``: + +* ``decide()`` cascade behaviour +* probe-result parsing (``_extract_last_json_object``) +* CLI flag wiring for max-iteration overrides +* checker JSON field contracts used by the orchestrator + +The build sub-scripts themselves are *not* exercised here because they +would require real LLM calls; this test focuses on deterministic logic. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[1] +_SCRIPTS = _REPO / "scripts" + +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + +# ``plan.py`` is shipped under ``scripts/`` and not installed as a +# package, so load it via importlib. +_SPEC = importlib.util.spec_from_file_location("plan_orchestrator", _SCRIPTS / "plan.py") +assert _SPEC is not None and _SPEC.loader is not None +plan = importlib.util.module_from_spec(_SPEC) +sys.modules["plan_orchestrator"] = plan +_SPEC.loader.exec_module(plan) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _states(types: list[str]) -> list["plan.StageState"]: + """Build a list of StageState objects from the given type sequence.""" + assert len(types) == len(plan.STAGES) + return [ + plan.StageState(stage=stage, type=t, done=(t == "update")) + for stage, t in zip(plan.STAGES, types) + ] + + +def _load_script(name: str): + spec = importlib.util.spec_from_file_location(name, _SCRIPTS / f"{name}.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +# --------------------------------------------------------------------------- +# decide() cascade rule +# --------------------------------------------------------------------------- + +class TestDecideCascade: + def test_all_update_means_nothing_runs(self) -> None: + states = _states(["update"] * 5) + plan.decide(states, force=False) + assert [s.will_run for s in states] == [False] * 5 + + def test_fresh_workspace_runs_everything(self) -> None: + states = _states(["init"] * 5) + plan.decide(states, force=False) + assert [s.will_run for s in states] == [True] * 5 + + def test_partial_resume_runs_from_first_non_update(self) -> None: + # skeleton + data_flow done, base_classes init, rest init + states = _states(["update", "update", "init", "init", "init"]) + plan.decide(states, force=False) + assert [s.will_run for s in states] == [False, False, True, True, True] + + def test_cascade_forces_downstream_even_if_update(self) -> None: + # Inconsistent state: skeleton needs rebuild but base_classes + # is "update" (e.g. user manually deleted skeleton.json). + # Cascade rule must force base_classes to rebuild anyway. + states = _states(["init", "update", "update", "update", "update"]) + plan.decide(states, force=False) + assert [s.will_run for s in states] == [True, True, True, True, True] + # downstream reasons should mention cascade + assert "upstream" in states[1].reason + + def test_warning_triggers_run(self) -> None: + states = _states(["update", "warning", "update", "update", "update"]) + plan.decide(states, force=False) + # data_flow runs because warning; downstream cascades. + assert [s.will_run for s in states] == [False, True, True, True, True] + + def test_force_runs_everything(self) -> None: + states = _states(["update"] * 5) + plan.decide(states, force=True) + assert all(s.will_run for s in states) + assert all(s.reason == "forced" for s in states) + + +# --------------------------------------------------------------------------- +# _extract_last_json_object — tolerant JSON parsing. +# --------------------------------------------------------------------------- + +class TestExtractLastJsonObject: + def test_pure_json(self) -> None: + obj = plan._extract_last_json_object('{"type": "init"}') + assert obj == {"type": "init"} + + def test_json_with_trailing_text(self) -> None: + text = '{"type": "update", "ok": true}\n📸 snapshot abc123' + obj = plan._extract_last_json_object(text) + assert obj == {"type": "update", "ok": True} + + def test_json_with_leading_text(self) -> None: + text = 'Running ...\n{"type": "init"}' + obj = plan._extract_last_json_object(text) + assert obj == {"type": "init"} + + def test_takes_last_object_when_multiple(self) -> None: + text = '{"first": 1}{"type": "update"}' + obj = plan._extract_last_json_object(text) + assert obj == {"type": "update"} + + def test_returns_none_on_garbage(self) -> None: + assert plan._extract_last_json_object("no braces here") is None + assert plan._extract_last_json_object("{not json}") is None + + +# --------------------------------------------------------------------------- +# Per-stage max-iter flag wiring. +# --------------------------------------------------------------------------- + +class TestBuildArgs: + def test_max_iter_skeleton_uses_max_iterations_flag(self) -> None: + ns = plan._parse_args(["--max-iter-skeleton", "7"]) + skeleton = plan.STAGES[0] + assert skeleton.name == "skeleton" + args = plan._build_args_for(skeleton, ns) + assert "--max-iterations" in args + assert args[args.index("--max-iterations") + 1] == "7" + + def test_max_iter_interfaces_uses_max_file_iterations_flag(self) -> None: + # design_interfaces.py has a different flag name than the others. + ns = plan._parse_args(["--max-iter-interfaces", "4"]) + interfaces = next(s for s in plan.STAGES if s.name == "interfaces") + args = plan._build_args_for(interfaces, ns) + assert "--max-file-iterations" in args + assert "--max-iterations" not in args + assert args[args.index("--max-file-iterations") + 1] == "4" + + def test_tasks_stage_has_no_max_iter_flag(self) -> None: + # plan_tasks.py takes no iteration count; --max-iter-* must not be + # forwarded even if some other stage's flag is set. + ns = plan._parse_args(["--max-iter-skeleton", "9"]) + tasks = next(s for s in plan.STAGES if s.name == "tasks") + args = plan._build_args_for(tasks, ns) + assert all(not a.startswith("--max") for a in args) + + def test_verbose_forwarded(self) -> None: + ns = plan._parse_args(["--verbose"]) + args = plan._build_args_for(plan.STAGES[0], ns) + assert "--verbose" in args + + def test_no_trajectory_forwarded(self) -> None: + ns = plan._parse_args(["--no-trajectory"]) + args = plan._build_args_for(plan.STAGES[0], ns) + assert "--no-trajectory" in args + + +# --------------------------------------------------------------------------- +# Stage table sanity — guard against silent registry drift. +# --------------------------------------------------------------------------- + +class TestCheckerContracts: + @pytest.mark.parametrize( + ("script_name", "args"), + [ + ("check_data_flow", (Path("missing-data-flow.json"), Path("missing-skeleton.json"))), + ("check_base_classes", (Path("missing-base-classes.json"),)), + ], + ) + def test_plan_checkers_emit_type_not_state(self, script_name: str, args: tuple[Path, ...]) -> None: + checker = _load_script(script_name) + result = checker.inspect_state(*args) + assert result["type"] == "init" + assert "state" not in result + + +class TestStageRegistry: + def test_five_stages_in_canonical_order(self) -> None: + assert [s.name for s in plan.STAGES] == [ + "skeleton", + "data_flow", + "base_classes", + "interfaces", + "tasks", + ] + + @pytest.mark.parametrize("stage", plan.STAGES) + def test_every_stage_has_a_build_and_check_script(self, stage: plan.Stage) -> None: + assert (_SCRIPTS / stage.build_script).is_file(), stage.build_script + assert (_SCRIPTS / stage.check_script).is_file(), stage.check_script + + @pytest.mark.parametrize("post_script", plan.POST_STEPS) + def test_post_step_scripts_exist(self, post_script: str) -> None: + assert (_SCRIPTS / post_script).is_file(), post_script