From 1c47c1d6133f3defd16bb53879dadd189285e8c5 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Mon, 18 May 2026 21:46:07 +0800 Subject: [PATCH 01/31] feat(rpgkit): package bundle mode + workspace AI config (v0.1.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship scripts/ and templates/commands/ as packaged assets inside the wheel (rpgkit_cli/core_pack/) via hatch force-include, so 'rpgkit init' works offline (air-gapped / corporate-proxy / enterprise environments). Replace the build-time placeholder substitution with a runtime resolver in scripts/common/llm_client.py that reads .rpgkit/config.toml per workspace. This decouples scripts from the chosen AI agent and is forward-compatible with running scripts from a shared installation in the future. Key changes: * pyproject.toml: bump to 0.1.3 + force-include scripts/ and templates/commands/ * src/rpgkit_cli/_assets.py (new): importlib.resources access to core_pack * src/rpgkit_cli/__init__.py: - _AI_TO_CLI_CMD authoritative map (mirrors release-zip CI) - _install_from_bundle() + _materialise_commands_for_agent() - _write_workspace_config() materialises .rpgkit/config.toml - .rpgkit/.source marker so 'rpgkit update' honours the user's channel - download_and_extract_template() dispatches: bundle by default, falls back to legacy release zip when --legacy-download is passed (or --pre, or when the bundle is unavailable / --script ps is requested) - 'rpgkit init': new --legacy-download flag - 'rpgkit update': new --legacy-download and --pull flags - _detect_install_method() + _upgrade_command() power --pull * scripts/common/llm_client.py: - _load_ai_cli_cmd() with P1-P4 priority chain (constructor arg → env var → workspace config.toml → release-zip-baked-in fallback) - detect_agent_type() now resolves at call time, not module import - LLMClient.__init__ tolerates empty tool; generate() raises lazily with an actionable error message when the workspace is unconfigured * scripts/__init__.py (new, empty): marker for hatch force-include * .gitignore: un-ignore .rpgkit/config.toml so teams can commit the workspace default Behavioural guarantees (7a route): * Scripts still land under /.rpgkit/scripts/ * No slash-command template was modified * MCP config generation unchanged * Hook installation logic unchanged * All 27 pipeline scripts unchanged * No new tests; the 11 pre-existing test failures on main remain unchanged Design notes live in plans/01-package-bundle-and-ai-config.md (gitignored; local reference only). --- RPG-Kit/.gitignore | 3 + RPG-Kit/pyproject.toml | 11 +- RPG-Kit/scripts/__init__.py | 17 + RPG-Kit/scripts/common/llm_client.py | 125 ++++++- RPG-Kit/src/rpgkit_cli/__init__.py | 498 ++++++++++++++++++++++++++- RPG-Kit/src/rpgkit_cli/_assets.py | 73 ++++ 6 files changed, 713 insertions(+), 14 deletions(-) create mode 100644 RPG-Kit/scripts/__init__.py create mode 100644 RPG-Kit/src/rpgkit_cli/_assets.py diff --git a/RPG-Kit/.gitignore b/RPG-Kit/.gitignore index 75fae69..fafac9a 100644 --- a/RPG-Kit/.gitignore +++ b/RPG-Kit/.gitignore @@ -227,6 +227,9 @@ plans/ # RPG-Kit ignores (managed by `rpgkit init/update`) .rpgkit/ +# But DO commit the workspace AI config so collaborators get a sane default. +# Plan: plans/01-package-bundle-and-ai-config.md decision 15. +!.rpgkit/config.toml .vscode/mcp.json .vscode/tasks.json .mcp.json diff --git a/RPG-Kit/pyproject.toml b/RPG-Kit/pyproject.toml index 545a3a5..2ed39ec 100644 --- a/RPG-Kit/pyproject.toml +++ b/RPG-Kit/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rpgkit-cli" -version = "0.1.2" +version = "0.1.3" description = "RPG-Kit CLI - A tool to generate feature trees for repository planning and code generation." requires-python = ">=3.12" dependencies = [ @@ -39,3 +39,12 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/rpgkit_cli"] + +# Bundle core assets (scripts + slash-command templates) into the wheel under +# `rpgkit_cli/core_pack/` so that `rpgkit init` works offline (air-gapped / +# corporate-proxy / enterprise environments). These are the SAME source files +# the GitHub Release zip workflow packages; bundling them in the wheel just +# gives users a network-free fast path. Plan: plans/01-package-bundle-and-ai-config.md +[tool.hatch.build.targets.wheel.force-include] +"scripts" = "rpgkit_cli/core_pack/scripts" +"templates/commands" = "rpgkit_cli/core_pack/commands" diff --git a/RPG-Kit/scripts/__init__.py b/RPG-Kit/scripts/__init__.py new file mode 100644 index 0000000..32626eb --- /dev/null +++ b/RPG-Kit/scripts/__init__.py @@ -0,0 +1,17 @@ +# This file is intentionally empty. +# +# It exists so that hatch treats ``scripts/`` as a discoverable subtree +# for the ``force-include`` directive in ``pyproject.toml`` (see +# ``[tool.hatch.build.targets.wheel.force-include]``). Some hatch +# versions skip top-level directories that lack an ``__init__.py`` when +# walking the source tree, even though ``force-include`` should not +# require Python-package semantics. Keeping this empty marker file +# avoids surprising build differences across hatch releases. +# +# At runtime ``scripts/`` is NOT imported as ``rpgkit_cli.scripts`` +# — the wheel's ``force-include`` rewrites the install target to +# ``rpgkit_cli/core_pack/scripts/``, and that path is also not imported +# as a Python module. Callers always copy scripts into the user's +# workspace and invoke them with ``python /.rpgkit/scripts/.py``. +# +# Plan: ``plans/01-package-bundle-and-ai-config.md`` diff --git a/RPG-Kit/scripts/common/llm_client.py b/RPG-Kit/scripts/common/llm_client.py index a6f16b5..abafee3 100644 --- a/RPG-Kit/scripts/common/llm_client.py +++ b/RPG-Kit/scripts/common/llm_client.py @@ -14,6 +14,7 @@ import signal as _signal import subprocess import time +import tomllib from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -21,6 +22,7 @@ from common.llm_types import Memory from common.session_manager import create_session_manager +from . import paths as _paths from .paths import REPO_DIR as _REPO_DIR, WORKSPACE_ROOT as _WORKSPACE_ROOT @@ -33,8 +35,85 @@ def _set_pdeathsig() -> None: pass -# Default AI assistant command -AI_CLI_CMD = "" +# ---------------------------------------------------------------------------- +# AI CLI command resolution (added in rpgkit-cli 0.1.3) +# ---------------------------------------------------------------------------- +# +# Pre-0.1.3 each release-zip variant pre-substituted ```` for +# the chosen agent at packaging time, so this module just exposed the +# literal string. Bundle mode does not do that string-substitution dance +# (one bundle serves every AI), so we resolve at runtime instead with a +# P1-P4 priority chain. See plans/01-package-bundle-and-ai-config.md §3. +# +# P1. LLMClient(tool="...") — explicit constructor argument +# P2. RPGKIT_AI_CLI_CMD env var — for CI / tests / ad-hoc override +# P3. .rpgkit/config.toml [rpgkit].ai_cli_cmd +# P4. Module-level baked-in value — release-zip CI replaces the +# literal "" with the +# concrete command at packaging +# time, keeping the legacy path +# working transparently. +# +# Returning empty string is OK at module-import time and at LLMClient +# construction; the error is raised lazily in :meth:`LLMClient.generate` +# so unit tests and tools that construct an LLMClient without intending +# to invoke the LLM continue to work. + +_PLACEHOLDER_LITERAL = "" + +# Module-level default — the release-zip CI replaces this exact literal +# with the chosen agent's command at packaging time. Bundle installs +# leave it unchanged and rely on P2/P3 to supply the value. +_BAKED_IN_VALUE = _PLACEHOLDER_LITERAL + + +def _load_ai_cli_cmd() -> str: + """Resolve the AI CLI command string via the P1-P4 priority chain. + + P1 is handled by :class:`LLMClient.__init__` (constructor argument). + This function implements P2-P4 and returns ``""`` if none of them + yield a usable value — callers decide how to react. + + The workspace root is *re-resolved at every invocation* via + :func:`paths._find_workspace_root`, not via the import-frozen + :data:`paths.WORKSPACE_ROOT` constant. This matters for long-lived + processes that may serve more than one workspace (e.g. a future + global MCP server). See plan §2.5/Z. + """ + # P2: env var (highest non-P1 priority — useful in tests and one-off + # overrides without editing the workspace config). + env_val = _os.environ.get("RPGKIT_AI_CLI_CMD", "").strip() + if env_val: + return env_val + + # P3: workspace config.toml. + try: + workspace = _paths._find_workspace_root() + cfg_path = workspace / ".rpgkit" / "config.toml" + if cfg_path.exists(): + with open(cfg_path, "rb") as f: + data = tomllib.load(f) + cfg_val = (data.get("rpgkit") or {}).get("ai_cli_cmd", "") + if isinstance(cfg_val, str): + cfg_val = cfg_val.strip() + if cfg_val: + return cfg_val + except Exception: + # Defensive: paths resolution, missing tomllib, or malformed TOML + # should never crash an LLMClient construction. Fall through. + pass + + # P4: legacy baked-in value (release-zip-substituted at build time). + if _BAKED_IN_VALUE and _BAKED_IN_VALUE != _PLACEHOLDER_LITERAL: + return _BAKED_IN_VALUE + + return "" + + +# Resolved once at import for backward-compat with callers that referenced +# the module-level constant directly. New code should call ``_load_ai_cli_cmd()`` +# or use ``LLMClient.tool`` (already populated through the same chain). +AI_CLI_CMD = _load_ai_cli_cmd() # Mapping from the first token of AI_CLI_CMD to the canonical agent name @@ -53,19 +132,23 @@ def _set_pdeathsig() -> None: } -def detect_agent_type() -> str: - """Detect which AI coding agent is being used based on AI_CLI_CMD. +def detect_agent_type(cmd: Optional[str] = None) -> str: + """Detect which AI coding agent is being used. - AI_CLI_CMD is a placeholder that gets replaced per-agent during - release packaging (e.g. "claude -p", "copilot -p", "codex exec"). + Args: + cmd: Optional explicit CLI command string. When omitted we + resolve dynamically via :func:`_load_ai_cli_cmd` so this + function reflects the current workspace's configuration, + not whatever was in effect at module import time. Returns one of: claude, gemini, copilot, cursor, codex, auggie, amp, opencode, codebuddy, qoder, qwen, unknown """ - if not AI_CLI_CMD: + cmd = cmd if cmd is not None else _load_ai_cli_cmd() + if not cmd or cmd == _PLACEHOLDER_LITERAL: return "unknown" - first_token = AI_CLI_CMD.strip().split()[0] + first_token = cmd.strip().split()[0] return _CLI_TO_AGENT.get(first_token, "unknown") @@ -148,18 +231,26 @@ def __init__( step_id: Current step ID in the trajectory logger: Logger instance """ - self.tool = tool or AI_CLI_CMD + # P1 (explicit arg) wins; otherwise P2-P4 chain via _load_ai_cli_cmd. + # The empty-string case is tolerated here so unit tests / utilities + # that construct an LLMClient without intending to invoke the LLM + # keep working. The actual error is raised in :meth:`generate` if + # the tool is still empty when a call is attempted. + # Plan §3, decision 19. + self.tool = tool if tool is not None else _load_ai_cli_cmd() self.trajectory = trajectory self.step_id = step_id self.logger = logger or logging.getLogger(__name__) - # Session manager — auto-determined from AI_CLI_CMD. + # Session manager — driven by detect_agent_type(self.tool) so the + # right subclass is chosen even when self.tool came from the + # workspace config (not the import-time AI_CLI_CMD constant). # project_dir must match the subprocess cwd (workspace root == REPO_DIR) # so that Claude CLI's session file path # (~/.claude/projects//) can be correctly located by # the session manager. self._session_manager = create_session_manager( - agent_type=detect_agent_type(), + agent_type=detect_agent_type(self.tool), project_dir=_REPO_DIR, trace_filename_builder=self._build_trace_filename, logger=self.logger, @@ -246,6 +337,18 @@ def generate( Raises: RuntimeError: If LLM call fails after all retries """ + # Lazy validation: the constructor tolerates an empty/placeholder + # ``self.tool`` so that tests and tools can build an LLMClient + # without triggering an LLM invocation, but the moment we are + # actually asked to call out, the configuration must be valid. + # Plan §3, decision 19. + if not self.tool or self.tool == _PLACEHOLDER_LITERAL: + raise RuntimeError( + "AI CLI command not configured. Run " + "`rpgkit init --ai ` in this workspace, or set the " + "RPGKIT_AI_CLI_CMD environment variable." + ) + # Create call record self._call_counter += 1 call_record = LLMCallRecord( diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index a054f5f..5b2ddc9 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -298,6 +298,167 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" + +# --------------------------------------------------------------------------- +# Bundle mode (packaged assets) — added in 0.1.3 +# --------------------------------------------------------------------------- +# +# rpgkit-cli ships ``scripts/`` and ``templates/commands/`` as packaged +# assets under ``rpgkit_cli/core_pack/`` so that ``rpgkit init`` works +# offline. See ``plans/01-package-bundle-and-ai-config.md`` for the +# full design. This block exposes: +# +# _AI_TO_CLI_CMD — single source of truth for "selected AI" → +# "AI CLI command to invoke from scripts". +# Must stay in sync with the corresponding case +# statement in +# ``.github/workflows/scripts/rpgkit/create-release-packages.sh`` +# (the release-zip pipeline) and with +# ``scripts/common/llm_client.py:_CLI_TO_AGENT`` +# (the reverse mapping consumed by detect_agent_type()). +# +# _SOURCE_BUNDLE / _SOURCE_LEGACY — values written to ``.rpgkit/.source`` +# so subsequent ``rpgkit update`` calls +# can honour the user's original choice. + +_AI_TO_CLI_CMD = { + # NOTE: values below are copied verbatim from + # .github/workflows/scripts/rpgkit/create-release-packages.sh lines ~142-169 + # to guarantee bundle mode and legacy-download mode behave identically. + "copilot": "copilot", + "claude": "claude", + "gemini": "gemini -p", + "qwen": "qwen -p", + "cursor-agent": "agent -p", + "auggie": "augment -p", + "codex": "codex exec", + "codebuddy": "codebuddy -p", + "qoder": "qodercli -p", + "opencode": "opencode run", + "amp": "amp --execute", +} + +_SOURCE_BUNDLE = "bundle" +_SOURCE_LEGACY = "legacy" +_SOURCE_MARKER_RELPATH = Path(".rpgkit") / ".source" +_CONFIG_RELPATH = Path(".rpgkit") / "config.toml" + + +def _read_source_marker(project_path: Path) -> str | None: + """Return previously recorded provisioning source, or ``None``.""" + marker = project_path / _SOURCE_MARKER_RELPATH + try: + return marker.read_text(encoding="utf-8").strip() or None + except FileNotFoundError: + return None + except OSError: + return None + + +def _write_source_marker(project_path: Path, source: str) -> None: + """Persist the provisioning source so subsequent updates honour it.""" + marker = project_path / _SOURCE_MARKER_RELPATH + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(source + "\n", encoding="utf-8") + + +def _write_workspace_config(project_path: Path, selected_ai: str) -> None: + """Materialise ``.rpgkit/config.toml`` with the selected AI's CLI command. + + Idempotent: if the file already exists and already contains + ``ai_cli_cmd``, leave it alone (the user may have customised it). + Only writes a fresh file when one is missing. + """ + cfg_path = project_path / _CONFIG_RELPATH + cli_cmd = _AI_TO_CLI_CMD.get(selected_ai, selected_ai) + + if cfg_path.exists(): + # Don't clobber user edits. We could merge here, but plain + # workspaces don't need the complexity and a stale value is a + # supported configuration (env var override remains available). + return + + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + "# RPG-Kit workspace configuration\n" + "# Managed by `rpgkit init` / `rpgkit update`. Safe to commit.\n" + "# See: https://github.com/microsoft/RPG-ZeroRepo (RPG-Kit/docs/configuration.md)\n" + "\n" + "[rpgkit]\n" + f'ai_cli_cmd = "{cli_cmd}"\n', + encoding="utf-8", + ) + + +def _detect_install_method() -> str: + """Best-effort detection of how ``rpgkit-cli`` was installed. + + Returns one of ``"uv"``, ``"pipx"``, ``"pip-user"``, ``"pip-system"``, + ``"editable"``, ``"unknown"``. Used by ``rpgkit update --pull`` to + pick the right upgrade command. + """ + try: + exe = Path(sys.executable).resolve() + exe_str = str(exe) + except Exception: + return "unknown" + + # uv tool install creates venvs under ~/.local/share/uv/tools// + # (or %LOCALAPPDATA%\uv\tools\\ on Windows). + exe_posix = exe_str.replace("\\", "/") + if "/uv/tools/" in exe_posix: + return "uv" + try: + # uv-receipt.json sits at the venv root one level above bin/. + if (exe.parent.parent / "uv-receipt.json").exists(): + return "uv" + except Exception: + pass + + # pipx puts each tool's venv under ~/.local/share/pipx/venvs// + if "/pipx/venvs/" in exe_posix: + return "pipx" + + # Editable install: package metadata has direct_url.json with editable=true. + try: + import importlib.metadata as _im + + dist = _im.distribution("rpgkit-cli") + durl = dist.read_text("direct_url.json") + if durl and '"editable": true' in durl: + return "editable" + except Exception: + pass + + # Plain pip: distinguish user-site vs system-site by path prefix. + try: + import site + + if site.ENABLE_USER_SITE and exe_str.startswith(site.getuserbase()): + return "pip-user" + except Exception: + pass + + return "pip-system" + + +def _upgrade_command(method: str) -> list[str] | None: + """Return the shell command argv that upgrades the installed CLI. + + Returns ``None`` when no automatic command is appropriate (editable + install, or unknown installer). + """ + if method == "uv": + return ["uv", "tool", "upgrade", "rpgkit-cli"] + if method == "pipx": + return ["pipx", "upgrade", "rpgkit-cli"] + if method == "pip-user": + return [sys.executable, "-m", "pip", "install", "-U", "--user", "rpgkit-cli"] + if method == "pip-system": + return [sys.executable, "-m", "pip", "install", "-U", "rpgkit-cli"] + return None + + # ── Default .gitignore template ────────────────────────────────────────── # Split into three parts so init can compose the right output depending on # project state: @@ -2765,10 +2926,250 @@ def download_and_extract_template( debug: bool = False, github_token: str = None, pre: bool = False, + legacy_download: bool = False, ) -> Path: - """Download the latest release and extract it to create a new project. + """Provision the workspace with scripts + command templates. + + Two provisioning sources are supported: + + * **Bundle (default)** — copy from packaged assets shipped inside + ``rpgkit_cli`` (no network). Used whenever :func:`_assets.available` + is True and ``legacy_download`` is False. + * **Legacy release zip** — original behaviour: query GitHub API for + the latest matching release, download the per-AI/script-type zip, + and extract it. Activated by ``legacy_download=True`` or when the + bundle is unavailable (editable installs, etc.). - Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup). + On ``--script ps`` the bundle path is rejected and the legacy path is + required (see ``plans/01-package-bundle-and-ai-config.md`` §2.5/C). + + Returns ``project_path``. Uses the supplied :class:`StepTracker` + to report progress when provided. + """ + from . import _assets + + # ---- Decide provisioning source ---- + use_bundle = (not legacy_download) and _assets.available() + + # Bundle currently ships only POSIX-shell-flavoured scripts (today + # the scripts/ tree has no bash/ or powershell/ subdirs, so the + # CI's per-shell partitioning is vestigial — see plan §2.5/C). + # When the user explicitly asks for PowerShell, fall back to the + # legacy zip path so that future PowerShell variants in releases + # keep working. + if use_bundle and script_type == "ps": + use_bundle = False + if tracker is None and verbose: + console.print( + "[yellow]--script ps requested: falling back to release-zip download " + "(bundle ships POSIX scripts only).[/yellow]" + ) + + if use_bundle: + return _install_from_bundle( + project_path, + ai_assistant, + script_type, + is_current_dir, + verbose=verbose, + tracker=tracker, + ) + + # Fall through to the original legacy zip path below. + return _download_and_extract_release_zip( + project_path, + ai_assistant, + script_type, + is_current_dir, + verbose=verbose, + tracker=tracker, + client=client, + debug=debug, + github_token=github_token, + pre=pre, + ) + + +def _install_from_bundle( + project_path: Path, + ai_assistant: str, + script_type: str, + is_current_dir: bool, + *, + verbose: bool = True, + tracker: StepTracker | None = None, +) -> Path: + """Copy packaged scripts + per-AI command templates into the workspace. + + Mirrors the post-extract layout produced by the legacy zip path so + that downstream steps (``ensure_executable_scripts``, + ``_setup_gitignore``, ``_generate_mcp_config``, ``_install_hooks``) + work unchanged. + """ + from . import _assets + + if tracker: + tracker.start("fetch", "packaged assets (offline)") + tracker.complete("fetch", "bundle ready") + tracker.add("download", "Download template") + tracker.skip("download", "bundle mode") + + if not is_current_dir: + project_path.mkdir(parents=True) + + if tracker: + tracker.start("extract") + + try: + rpgkit_root = project_path / ".rpgkit" + rpgkit_scripts = rpgkit_root / "scripts" + rpgkit_scripts.parent.mkdir(parents=True, exist_ok=True) + + # 1. Copy scripts. Use shutil.copytree with dirs_exist_ok so + # that re-running on an existing workspace overwrites stale + # pipeline scripts without complaint. + src_scripts = _assets.scripts_dir() + shutil.copytree( + src_scripts, + rpgkit_scripts, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns( + "__pycache__", "*.pyc", ".pytest_cache", ".mypy_cache" + ), + ) + + # 2. Copy slash-command templates filtered by selected AI. The + # union of all command markdown files lives under + # core_pack/commands/; we materialise into the AI-specific + # directory the agent expects (e.g. .claude/commands/ or + # .github/agents/+ .github/prompts/). + _install_command_templates_from_bundle( + project_path, ai_assistant, _assets.commands_dir() + ) + + # 3. Record the provisioning source so subsequent ``rpgkit update`` + # invocations default to the same channel. + _write_source_marker(project_path, _SOURCE_BUNDLE) + + if tracker: + tracker.start("zip-list") + tracker.complete("zip-list", "bundle") + tracker.start("extracted-summary") + tracker.complete("extracted-summary", "bundle copied") + tracker.complete("extract") + tracker.add("cleanup", "Remove temporary archive") + tracker.skip("cleanup", "bundle mode") + except Exception as e: + if tracker: + tracker.error("extract", str(e)) + else: + console.print(f"[red]Error installing from bundle:[/red] {e}") + raise + + return project_path + + +def _install_command_templates_from_bundle( + project_path: Path, + ai_assistant: str, + src_commands_dir: Path, +) -> None: + """Install slash-command templates from the bundle into the per-AI dir. + + The bundle's ``commands/`` directory ships the canonical (AI-agnostic) + template content. Each AI integration places these into a different + target directory with a different filename convention; this helper + delegates that mapping to :func:`_materialise_commands_for_agent`, + which is also used by the legacy zip path so behaviour is identical + across provisioning sources. + """ + agent_config = AGENT_CONFIG.get(ai_assistant) + if not agent_config: + # Unknown agent — copy raw markdown into a neutral location so + # nothing is lost; downstream init() will already have rejected + # this AI selection. + dest = project_path / ".rpgkit" / "commands" + shutil.copytree(src_commands_dir, dest, dirs_exist_ok=True) + return + + _materialise_commands_for_agent( + ai_assistant, src_commands_dir, project_path + ) + + +def _materialise_commands_for_agent( + ai_assistant: str, + src_commands_dir: Path, + project_path: Path, +) -> None: + """Place command templates into the agent-specific workspace location. + + This intentionally mirrors what the legacy release-zip path produces + (see ``.github/workflows/scripts/rpgkit/create-release-packages.sh`` + ``generate_commands`` / ``generate_copilot_prompts``), so that + downstream consumers see the same layout regardless of provisioning + source. + + Layout produced: + claude → ``.claude/commands/rpgkit..md`` + copilot → ``.github/agents/rpgkit..agent.md`` + ``.github/prompts/rpgkit..prompt.md`` (frontmatter + points at the corresponding agent) + others → fallback: ``.rpgkit/commands/rpgkit..md`` + + NOTE: ``claude`` and ``copilot`` are the only verified agents in + AGENT_CONFIG today. Add new agents here when AGENT_CONFIG grows. + """ + def _read_body(src: Path) -> str: + # Normalise CRLF → LF, matching what the CI's ``tr -d '\r'`` does. + return src.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + + if ai_assistant == "claude": + dest = project_path / ".claude" / "commands" + dest.mkdir(parents=True, exist_ok=True) + for src in src_commands_dir.glob("*.md"): + target = dest / f"rpgkit.{src.stem}.md" + target.write_text(_read_body(src), encoding="utf-8") + elif ai_assistant == "copilot": + agents = project_path / ".github" / "agents" + prompts = project_path / ".github" / "prompts" + agents.mkdir(parents=True, exist_ok=True) + prompts.mkdir(parents=True, exist_ok=True) + for src in src_commands_dir.glob("*.md"): + stem = f"rpgkit.{src.stem}" + body = _read_body(src) + (agents / f"{stem}.agent.md").write_text(body, encoding="utf-8") + # Copilot prompt files reference the agent by name in + # frontmatter; the body is empty so the agent prompt + # (already written above) is the source of truth. + (prompts / f"{stem}.prompt.md").write_text( + f"---\nagent: {stem}\n---\n", encoding="utf-8" + ) + else: + dest = project_path / ".rpgkit" / "commands" + dest.mkdir(parents=True, exist_ok=True) + for src in src_commands_dir.glob("*.md"): + (dest / f"rpgkit.{src.stem}.md").write_text(_read_body(src), encoding="utf-8") + + +def _download_and_extract_release_zip( + project_path: Path, + ai_assistant: str, + script_type: str, + is_current_dir: bool = False, + *, + verbose: bool = True, + tracker: StepTracker | None = None, + client: httpx.Client = None, + debug: bool = False, + github_token: str = None, + pre: bool = False, +) -> Path: + """Original release-zip download + extract path (pre-0.1.3 behaviour). + + Kept available for users that need the very latest prompts before the + next CLI release, or to bypass packaging glitches. Activated via + ``rpgkit init --legacy-download``. """ current_dir = Path.cwd() @@ -3131,6 +3532,16 @@ def init( "--no-mcp", help="Skip MCP server registration (rpg-tools won't be exposed to the AI agent)", ), + legacy_download: bool = typer.Option( + False, + "--legacy-download", + help=( + "Bypass the packaged assets (bundle) and download the latest " + "release zip from GitHub instead. Use when you need prompts " + "newer than the installed CLI release ships, or when bundle " + "mode misbehaves. Implied by --pre." + ), + ), encode: Optional[bool] = typer.Option( None, "--encode/--no-encode", @@ -3333,6 +3744,11 @@ def init( local_ssl_context = ssl_context if verify else False local_client = httpx.Client(verify=local_ssl_context) + # --pre implies --legacy-download (bundle has no notion of + # pre-release builds; the user is asking for newer prompts + # than the installed CLI release ships). Plan §2.5/D. + effective_legacy = legacy_download or pre + download_and_extract_template( project_path, selected_ai, @@ -3344,8 +3760,23 @@ def init( debug=debug, github_token=github_token, pre=pre, + legacy_download=effective_legacy, ) + # Persist provisioning source (bundle vs. legacy) so a later + # ``rpgkit update`` defaults to the same channel. The + # _install_from_bundle path already wrote `bundle`; record + # `legacy` for the alternative branch. Idempotent: the + # marker is overwritten on every init regardless. + from . import _assets as _assets_mod # local import: cheap, avoids cycles + if effective_legacy or not _assets_mod.available(): + _write_source_marker(project_path, _SOURCE_LEGACY) + + # Materialise .rpgkit/config.toml with the resolved AI CLI + # command. llm_client.py reads this at runtime to invoke + # the right sub-agent. Plan §3 / decision 13. + _write_workspace_config(project_path, selected_ai) + ensure_executable_scripts(project_path, tracker=tracker) # Materialize .gitignore *before* MCP/hook generation so the @@ -3597,6 +4028,24 @@ def update( "--no-mcp", help="Skip MCP server registration (rpg-tools won't be exposed to the AI agent)", ), + legacy_download: bool = typer.Option( + False, + "--legacy-download", + help=( + "Bypass packaged assets and re-sync from the latest GitHub " + "release zip. Use when prompts in a release are newer than " + "the CLI you have installed. Implied by --pre." + ), + ), + pull: bool = typer.Option( + False, + "--pull", + help=( + "Before syncing, run the appropriate upgrade command for the " + "installed CLI (uv / pipx / pip) so the latest packaged " + "assets are used. Requires network." + ), + ), ): """Update RPG-Kit template files in an existing project to the latest version. @@ -3712,6 +4161,42 @@ def update( local_ssl_context = ssl_context if verify else False local_client = httpx.Client(verify=local_ssl_context) + # --pull: self-upgrade the CLI first so the bundle we copy + # from is the latest. --pre and --legacy-download both + # imply the user wants the network path. Source-marker + # tracking honours the user's prior choice when no flag + # forces a particular channel. Plan §2.5/F, §5.3 step 8. + if pull: + method = _detect_install_method() + cmd = _upgrade_command(method) + if cmd is None: + console.print( + f"[yellow]--pull: cannot auto-upgrade for install method " + f"'{method}'. Upgrade manually, then re-run " + f"`rpgkit update`.[/yellow]" + ) + else: + console.print( + f"[cyan]Upgrading rpgkit-cli via {method}...[/cyan]" + ) + rc = subprocess.call(cmd) + if rc != 0: + console.print( + f"[yellow]CLI upgrade exited with code {rc}; " + f"continuing with currently installed version.[/yellow]" + ) + + prior_source = _read_source_marker(project_path) + effective_legacy = ( + legacy_download + or pre + or (prior_source == _SOURCE_LEGACY and not legacy_download) + ) + # If --legacy-download is explicitly passed, honour it. + # If neither flag is passed and no prior marker exists, default to bundle. + if not legacy_download and not pre and prior_source is None: + effective_legacy = False + download_and_extract_template( project_path, selected_ai, @@ -3723,8 +4208,17 @@ def update( debug=debug, github_token=github_token, pre=pre, + legacy_download=effective_legacy, ) + from . import _assets as _assets_mod + if effective_legacy or not _assets_mod.available(): + _write_source_marker(project_path, _SOURCE_LEGACY) + + # Refresh .rpgkit/config.toml only when missing (preserves + # user customisations on re-update). + _write_workspace_config(project_path, selected_ai) + ensure_executable_scripts(project_path, tracker=tracker) # Pre-create runtime directories so stage prompts that redirect diff --git a/RPG-Kit/src/rpgkit_cli/_assets.py b/RPG-Kit/src/rpgkit_cli/_assets.py new file mode 100644 index 0000000..e150556 --- /dev/null +++ b/RPG-Kit/src/rpgkit_cli/_assets.py @@ -0,0 +1,73 @@ +"""Locate bundled core_pack assets inside the installed package. + +The bundle is created at wheel-build time by hatch's ``force-include`` +(see ``pyproject.toml``). After ``uv tool install rpgkit-cli``, the +layout is:: + + /lib/python3.x/site-packages/rpgkit_cli/ + __init__.py + _assets.py + core_pack/ + scripts/ (full RPG-Kit/scripts/ tree) + commands/ (full RPG-Kit/templates/commands/ tree) + +``rpgkit init`` and ``rpgkit update`` copy from here to the workspace +when bundle mode is active (the default). When the bundle is absent +(typically in an editable install where ``force-include`` does not run), +:func:`available` returns ``False`` and callers should fall back to the +legacy GitHub-release-zip download path. + +Design notes +------------ +- We deliberately use :func:`importlib.resources.files` rather than + ``__file__`` arithmetic so that future packaging formats (zip-imports, + in-memory loaders) keep working. +- The returned path is a *filesystem* path (not a Traversable) because + the consumers (``shutil.copytree`` etc.) need real paths. This works + for the default wheel layout; if we ever ship as a zipapp this code + will need ``as_file()`` contexts. +- All functions are pure / side-effect-free. No mutation of the bundle. + +Plan: ``plans/01-package-bundle-and-ai-config.md`` +""" + +from __future__ import annotations + +from importlib.resources import files +from pathlib import Path + + +def core_pack_root() -> Path: + """Absolute path to the bundled ``core_pack/`` directory. + + Returns the path regardless of whether it exists on disk — callers + should check :func:`available` before using the path. + """ + return Path(str(files("rpgkit_cli").joinpath("core_pack"))) + + +def available() -> bool: + """True iff a usable bundle exists in the installed package. + + Returns ``False`` for editable installs (where ``force-include`` did + not run) and for any other situation where the bundle is missing + or incomplete. Callers use this to decide whether to fall back to + the legacy GitHub-release-zip download path. + """ + root = core_pack_root() + return root.is_dir() and (root / "scripts").is_dir() + + +def scripts_dir() -> Path: + """Directory containing the bundled RPG-Kit pipeline scripts.""" + return core_pack_root() / "scripts" + + +def commands_dir() -> Path: + """Directory containing the bundled slash-command templates.""" + return core_pack_root() / "commands" + + +def mcp_server_path() -> Path: + """Convenience: path to the bundled MCP server entry script.""" + return scripts_dir() / "mcp_server.py" From 1f8809abc300d28538c2b262eb2119105b511580 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Mon, 18 May 2026 21:49:46 +0800 Subject: [PATCH 02/31] docs(rpgkit): document bundle mode, --legacy-download/--pull, .rpgkit/config.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * README: add Updating section explaining the three update flavours (default offline, --pull, --legacy-download) + note that bundle mode is the new default since 0.1.3. * docs/cli-reference: list --legacy-download (init + update) and --pull (update), plus a Provisioning sources table summarising the two channels and the .rpgkit/.source marker. * docs/configuration: new Workspace Configuration section covering .rpgkit/config.toml, the P1-P4 resolution priority chain, and the authoritative --ai → ai_cli_cmd mapping. * docs/project-structure: surface config.toml and .source in the workspace tree. --- RPG-Kit/README.md | 25 ++++++++++++++++ RPG-Kit/docs/cli-reference.md | 14 ++++++++- RPG-Kit/docs/configuration.md | 49 +++++++++++++++++++++++++++++++ RPG-Kit/docs/project-structure.md | 2 ++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/RPG-Kit/README.md b/RPG-Kit/README.md index f44847c..3cb05f4 100644 --- a/RPG-Kit/README.md +++ b/RPG-Kit/README.md @@ -103,6 +103,31 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` +Since `0.1.3` the wheel ships the pipeline scripts and slash-command templates as packaged assets, so `rpgkit init` works offline (air-gapped, corporate proxy, etc.). To force the older GitHub-release-zip download path — e.g. to grab prompts newer than the installed CLI — pass `--legacy-download`. + +### Updating RPG-Kit + +Two flavours of update exist; pick the one that matches what you want to change. + +```bash +# (A) Refresh THIS workspace from the currently-installed CLI version +# (offline, fast, idempotent) +cd my-project +rpgkit update + +# (B) Upgrade the CLI itself, then refresh THIS workspace +# (network — uv / pipx / pip is auto-detected) +cd my-project +rpgkit update --pull + +# (C) Bypass the bundled assets and pull from the latest GitHub release zip +# (useful when a prompt fix has shipped but the CLI release has not yet) +cd my-project +rpgkit update --legacy-download +``` + +`rpgkit update` records the channel you chose in `.rpgkit/.source` so subsequent runs default to the same source. Your edits to `.rpgkit/config.toml` (see [`docs/configuration.md`](docs/configuration.md)) are preserved across updates. + ## Quick Start: New Repository Use this path when you want RPG-Kit to turn requirements into a new codebase. diff --git a/RPG-Kit/docs/cli-reference.md b/RPG-Kit/docs/cli-reference.md index bb6ab41..e9a6332 100644 --- a/RPG-Kit/docs/cli-reference.md +++ b/RPG-Kit/docs/cli-reference.md @@ -25,6 +25,7 @@ rpgkit init . [options] | `--ignore-agent-tools` | Skip checks for AI agent CLI tools | | `--github-token ` | GitHub token for private repos or higher rate limits | | `--pre` | Download the latest pre-release template | +| `--legacy-download` | Bypass the packaged assets and pull templates from the latest GitHub release zip (implied by `--pre`) | | `--skip-tls` | Skip SSL/TLS verification | | `--encode/--no-encode` | Run or skip initial RPG encoding at the end of init | | `--debug` | Show verbose diagnostic output | @@ -70,11 +71,22 @@ rpgkit update --github-token $GITHUB_TOKEN | `--script ` | Script type: `sh` (POSIX) or `ps` (PowerShell) | | `--github-token ` | GitHub token for private repos or higher rate limits | | `--pre` | Download the latest pre-release template | +| `--legacy-download` | Bypass the packaged assets and pull from the latest GitHub release zip (implied by `--pre`) | +| `--pull` | Self-upgrade the CLI (auto-detects uv / pipx / pip) before syncing the workspace | | `--no-mcp` | Skip MCP server configuration | | `--skip-tls` | Skip SSL/TLS verification | | `--debug` | Show verbose diagnostic output | -## `rpgkit check` +### Provisioning sources + +Since `0.1.3`, `rpgkit init` and `rpgkit update` provision from two channels: + +| Channel | When used | Network needed | +| ------- | --------- | -------------- | +| Packaged assets (bundle) | Default. Pulled from `rpgkit_cli/core_pack/` inside the installed wheel | No | +| GitHub release zip (legacy) | `--legacy-download`, `--pre`, or `--script ps`, or when the bundle is unavailable (e.g. editable installs) | Yes | + +`rpgkit init` writes the choice to `.rpgkit/.source` (`bundle` or `legacy`) so subsequent `rpgkit update` invocations default to the same channel. Override with the flag of your choice at any time. Verify that required tools are installed. diff --git a/RPG-Kit/docs/configuration.md b/RPG-Kit/docs/configuration.md index 9b5d36b..4674fba 100644 --- a/RPG-Kit/docs/configuration.md +++ b/RPG-Kit/docs/configuration.md @@ -21,6 +21,55 @@ rpgkit check If the selected AI assistant is not found, install and authenticate it, then rerun `rpgkit init` or `rpgkit update`. +## Workspace Configuration (`.rpgkit/config.toml`) + +Since `0.1.3`, every workspace owns a `.rpgkit/config.toml` file that records which AI CLI command the pipeline scripts should invoke. This decouples the scripts from a single AI at packaging time — the same packaged scripts now serve any AI you pick. + +```toml +# .rpgkit/config.toml +[rpgkit] +ai_cli_cmd = "claude" +``` + +The file is created automatically by `rpgkit init --ai `. Edit it any time to switch the workspace to a different AI; no need to re-run `init`. + +### Resolution priority + +When a pipeline script (or a hook, or the MCP server) needs to invoke the AI CLI, it resolves the command via the following chain. The first non-empty value wins: + +| # | Source | Use case | +| - | ------ | -------- | +| P1 | `LLMClient(tool="...")` constructor argument | Programmatic override (rare) | +| P2 | `RPGKIT_AI_CLI_CMD` environment variable | CI runs, one-off experiments | +| P3 | `.rpgkit/config.toml` `[rpgkit].ai_cli_cmd` | Normal default (per workspace) | +| P4 | Release-zip baked-in literal | Workspaces provisioned with `--legacy-download` | + +If all four resolve to empty, the next `LLMClient.generate()` call raises a `RuntimeError` instructing the user to run `rpgkit init` or set the env var. + +### Supported AI CLI commands + +The values written to `ai_cli_cmd` mirror the per-AI substitutions performed by the GitHub release-zip CI: + +| `--ai` value | `ai_cli_cmd` | +| ------------ | ------------ | +| `copilot` | `copilot` | +| `claude` | `claude` | +| `gemini` | `gemini -p` | +| `qwen` | `qwen -p` | +| `cursor-agent` | `agent -p` | +| `auggie` | `augment -p` | +| `codex` | `codex exec` | +| `codebuddy` | `codebuddy -p` | +| `qoder` | `qodercli -p` | +| `opencode` | `opencode run` | +| `amp` | `amp --execute` | + +Only `copilot` and `claude` are currently verified end-to-end; the others are scaffolded but may need integration adjustments. + +### Other config keys + +The `[rpgkit]` table currently holds only `ai_cli_cmd`. Future releases will add timeouts, retry budgets, and model overrides under the same namespace; older keys remain forward-compatible. + ## Initialization Options ### AI assistant selection diff --git a/RPG-Kit/docs/project-structure.md b/RPG-Kit/docs/project-structure.md index 8a3e466..d950c53 100644 --- a/RPG-Kit/docs/project-structure.md +++ b/RPG-Kit/docs/project-structure.md @@ -40,6 +40,8 @@ my-project/ │ ├── mcp.json # MCP server registration │ └── tasks.json # Optional workspace tasks └── .rpgkit/ + ├── config.toml # Workspace AI / config (committed). See docs/configuration.md + ├── .source # Provisioning channel marker: "bundle" or "legacy" ├── scripts/ # Pipeline scripts and support packages │ ├── feature_spec_to_json.py # Feature specification │ ├── feature_build.py From 4df0a90dc837c15b8296c8a453926c9293b0366f Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Mon, 18 May 2026 22:35:26 +0800 Subject: [PATCH 03/31] fix(rpgkit): post-review bug fixes in 0.1.3 plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight issues surfaced during a second-pass review of the bundle / config work landed in commit 1c47c1d. All fixes are local to src/rpgkit_cli/__init__.py; no scripts, templates, hooks, or MCP logic were touched. 1. _detect_install_method() reordered editable detection before uv. Previously an editable install placed in a uv-managed venv would be reported as 'uv' and 'rpgkit update --pull' would run 'uv tool upgrade' instead of telling the user to git pull. 2. --script ps fallback notice was guarded by 'if tracker is None and verbose:' which is never True in the actual init()/update() call path, so users never saw the message. Emit it through the tracker as a skipped 'ps-fallback' step so it shows up in the live status report. 3. --pull self-upgrade is now executed BEFORE constructing rich.Live instead of from inside the Live context. Mixing subprocess output (and the subsequent os.execvp) with Live's terminal control left a corrupted screen state. 4. --pull no longer continues the update flow with stale in-memory code after upgrading the wheel. On successful upgrade we os.execvp() the rpgkit binary (with --pull stripped) so the new logic runs against the freshly-installed core_pack. 5. .rpgkit/.source provisioning marker is now written by whichever path actually ran (_install_from_bundle for bundle, _download_and_extract_release_zip for legacy zip). The previous init/update logic decided based on flag state, which gave wrong results in the --script ps fallback case (bundle attempt → ps fallback to legacy zip → marker incorrectly left absent). 6. Removed redundant _install_command_templates_from_bundle wrapper. _materialise_commands_for_agent now also produces the rpgkit..md filename for unknown agents, matching the supported-agent paths. 7. _install_from_bundle no longer re-adds tracker step keys ('download', 'cleanup') that init()/update() had already registered; it just transitions them with .skip() to keep the live status report coherent. 8. Update()'s 'effective_legacy' boolean was the OR of three terms one of which redundantly re-checked another flag, followed by a no-op reassignment. Simplified to a single OR. No behaviour change for the offline-bundle happy path; smoke tests continue to show 883 passed / 11 pre-existing failures (no regression). --- RPG-Kit/src/rpgkit_cli/__init__.py | 207 ++++++++++++++--------------- 1 file changed, 103 insertions(+), 104 deletions(-) diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 5b2ddc9..7acee86 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -403,9 +403,24 @@ def _detect_install_method() -> str: except Exception: return "unknown" + exe_posix = exe_str.replace("\\", "/") + + # IMPORTANT: editable detection must run FIRST. An editable install + # placed inside a uv-managed venv would otherwise be reported as + # "uv" and ``rpgkit update --pull`` would try to upgrade from the + # registry instead of asking the user to `git pull` their checkout. + try: + import importlib.metadata as _im + + dist = _im.distribution("rpgkit-cli") + durl = dist.read_text("direct_url.json") + if durl and '"editable": true' in durl: + return "editable" + except Exception: + pass + # uv tool install creates venvs under ~/.local/share/uv/tools// # (or %LOCALAPPDATA%\uv\tools\\ on Windows). - exe_posix = exe_str.replace("\\", "/") if "/uv/tools/" in exe_posix: return "uv" try: @@ -419,17 +434,6 @@ def _detect_install_method() -> str: if "/pipx/venvs/" in exe_posix: return "pipx" - # Editable install: package metadata has direct_url.json with editable=true. - try: - import importlib.metadata as _im - - dist = _im.distribution("rpgkit-cli") - durl = dist.read_text("direct_url.json") - if durl and '"editable": true' in durl: - return "editable" - except Exception: - pass - # Plain pip: distinguish user-site vs system-site by path prefix. try: import site @@ -2956,14 +2960,19 @@ def download_and_extract_template( # CI's per-shell partitioning is vestigial — see plan §2.5/C). # When the user explicitly asks for PowerShell, fall back to the # legacy zip path so that future PowerShell variants in releases - # keep working. + # keep working. The notice is emitted through the tracker (when + # present) so it is actually visible during init/update. if use_bundle and script_type == "ps": use_bundle = False - if tracker is None and verbose: - console.print( - "[yellow]--script ps requested: falling back to release-zip download " - "(bundle ships POSIX scripts only).[/yellow]" - ) + notice = ( + "--script ps: falling back to release-zip download " + "(bundle ships POSIX-shell-oriented scripts only)" + ) + if tracker: + tracker.add("ps-fallback", "PowerShell fallback") + tracker.skip("ps-fallback", notice) + elif verbose: + console.print(f"[yellow]{notice}[/yellow]") if use_bundle: return _install_from_bundle( @@ -3009,10 +3018,12 @@ def _install_from_bundle( from . import _assets if tracker: + # init()/update() already registered fetch/download/extract step keys, + # so just transition them through completed states instead of + # re-adding (which would overwrite the existing label). tracker.start("fetch", "packaged assets (offline)") tracker.complete("fetch", "bundle ready") - tracker.add("download", "Download template") - tracker.skip("download", "bundle mode") + tracker.skip("download", "bundle mode (no network)") if not is_current_dir: project_path.mkdir(parents=True) @@ -3038,13 +3049,14 @@ def _install_from_bundle( ), ) - # 2. Copy slash-command templates filtered by selected AI. The - # union of all command markdown files lives under - # core_pack/commands/; we materialise into the AI-specific - # directory the agent expects (e.g. .claude/commands/ or - # .github/agents/+ .github/prompts/). - _install_command_templates_from_bundle( - project_path, ai_assistant, _assets.commands_dir() + # 2. Materialise slash-command templates into the AI-specific + # directory. _materialise_commands_for_agent owns the + # per-agent file-name / folder rules and matches what the + # legacy zip path produces (down to the rpgkit..md + # prefix), so downstream consumers see the same layout + # regardless of provisioning source. + _materialise_commands_for_agent( + ai_assistant, _assets.commands_dir(), project_path ) # 3. Record the provisioning source so subsequent ``rpgkit update`` @@ -3052,12 +3064,9 @@ def _install_from_bundle( _write_source_marker(project_path, _SOURCE_BUNDLE) if tracker: - tracker.start("zip-list") - tracker.complete("zip-list", "bundle") - tracker.start("extracted-summary") - tracker.complete("extracted-summary", "bundle copied") + tracker.skip("zip-list", "bundle (no archive)") + tracker.skip("extracted-summary", "bundle copied") tracker.complete("extract") - tracker.add("cleanup", "Remove temporary archive") tracker.skip("cleanup", "bundle mode") except Exception as e: if tracker: @@ -3069,34 +3078,6 @@ def _install_from_bundle( return project_path -def _install_command_templates_from_bundle( - project_path: Path, - ai_assistant: str, - src_commands_dir: Path, -) -> None: - """Install slash-command templates from the bundle into the per-AI dir. - - The bundle's ``commands/`` directory ships the canonical (AI-agnostic) - template content. Each AI integration places these into a different - target directory with a different filename convention; this helper - delegates that mapping to :func:`_materialise_commands_for_agent`, - which is also used by the legacy zip path so behaviour is identical - across provisioning sources. - """ - agent_config = AGENT_CONFIG.get(ai_assistant) - if not agent_config: - # Unknown agent — copy raw markdown into a neutral location so - # nothing is lost; downstream init() will already have rejected - # this AI selection. - dest = project_path / ".rpgkit" / "commands" - shutil.copytree(src_commands_dir, dest, dirs_exist_ok=True) - return - - _materialise_commands_for_agent( - ai_assistant, src_commands_dir, project_path - ) - - def _materialise_commands_for_agent( ai_assistant: str, src_commands_dir: Path, @@ -3115,7 +3096,9 @@ def _materialise_commands_for_agent( copilot → ``.github/agents/rpgkit..agent.md`` ``.github/prompts/rpgkit..prompt.md`` (frontmatter points at the corresponding agent) - others → fallback: ``.rpgkit/commands/rpgkit..md`` + others → fallback: ``.rpgkit/commands/rpgkit..md`` (same + ``rpgkit..md`` prefix for consistency with the + supported agents above) NOTE: ``claude`` and ``copilot`` are the only verified agents in AGENT_CONFIG today. Add new agents here when AGENT_CONFIG grows. @@ -3146,6 +3129,9 @@ def _read_body(src: Path) -> str: f"---\nagent: {stem}\n---\n", encoding="utf-8" ) else: + # Unknown agent (init() validates against AGENT_CONFIG so this + # branch is unreachable from the public CLI, but provides a + # well-defined behaviour if a future caller bypasses validation). dest = project_path / ".rpgkit" / "commands" dest.mkdir(parents=True, exist_ok=True) for src in src_commands_dir.glob("*.md"): @@ -3350,6 +3336,11 @@ def _download_and_extract_release_zip( elif verbose: console.print(f"Cleaned up: {zip_path.name}") + # Record provisioning source so a later ``rpgkit update`` defaults + # to the same channel. Counterpart to ``_install_from_bundle`` which + # writes ``bundle``. Plan §2.5 (decision 12). + _write_source_marker(project_path, _SOURCE_LEGACY) + return project_path @@ -3763,14 +3754,8 @@ def init( legacy_download=effective_legacy, ) - # Persist provisioning source (bundle vs. legacy) so a later - # ``rpgkit update`` defaults to the same channel. The - # _install_from_bundle path already wrote `bundle`; record - # `legacy` for the alternative branch. Idempotent: the - # marker is overwritten on every init regardless. - from . import _assets as _assets_mod # local import: cheap, avoids cycles - if effective_legacy or not _assets_mod.available(): - _write_source_marker(project_path, _SOURCE_LEGACY) + # .rpgkit/.source is written by whichever provisioning path + # actually ran (_install_from_bundle / _download_and_extract_release_zip). # Materialise .rpgkit/config.toml with the resolved AI CLI # command. llm_client.py reads this at runtime to invoke @@ -4127,6 +4112,47 @@ def update( console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") + # --pull: self-upgrade the CLI BEFORE building the live tracker so + # that the subprocess output (and the subsequent ``os.execvp``) does + # not have to fight with rich.Live for terminal control. Plan §2.5/F. + # + # After a successful upgrade we re-exec the (now upgraded) rpgkit + # binary to run the rest of the update flow. This avoids the + # staleness footgun where the running Python process still has the + # old CLI code in memory while the filesystem now holds the new + # core_pack/ assets — mixing them would invite subtle bugs (new + # prompts copied by old logic). Re-exec gives us a clean separation. + if pull: + method = _detect_install_method() + cmd = _upgrade_command(method) + if cmd is None: + console.print( + f"[yellow]--pull: cannot auto-upgrade for install method " + f"'{method}'. Upgrade manually, then re-run " + f"`rpgkit update`.[/yellow]" + ) + else: + console.print( + f"[cyan]Upgrading rpgkit-cli via {method}...[/cyan]" + ) + rc = subprocess.call(cmd) + if rc == 0: + # Re-exec without --pull so we run the upgraded logic + # against the freshly-installed core_pack. All other + # CLI flags are preserved verbatim. + new_argv = [a for a in sys.argv if a != "--pull"] + rpgkit_bin = shutil.which("rpgkit") or new_argv[0] + console.print( + "[cyan]CLI upgrade complete; re-exec'ing to apply " + "new templates...[/cyan]" + ) + os.execvp(rpgkit_bin, [rpgkit_bin, *new_argv[1:]]) + else: + console.print( + f"[yellow]CLI upgrade exited with code {rc}; " + f"continuing with currently installed version.[/yellow]" + ) + # Build step tracker tracker = StepTracker("Update RPG-Kit Project") @@ -4161,41 +4187,15 @@ def update( local_ssl_context = ssl_context if verify else False local_client = httpx.Client(verify=local_ssl_context) - # --pull: self-upgrade the CLI first so the bundle we copy - # from is the latest. --pre and --legacy-download both - # imply the user wants the network path. Source-marker - # tracking honours the user's prior choice when no flag - # forces a particular channel. Plan §2.5/F, §5.3 step 8. - if pull: - method = _detect_install_method() - cmd = _upgrade_command(method) - if cmd is None: - console.print( - f"[yellow]--pull: cannot auto-upgrade for install method " - f"'{method}'. Upgrade manually, then re-run " - f"`rpgkit update`.[/yellow]" - ) - else: - console.print( - f"[cyan]Upgrading rpgkit-cli via {method}...[/cyan]" - ) - rc = subprocess.call(cmd) - if rc != 0: - console.print( - f"[yellow]CLI upgrade exited with code {rc}; " - f"continuing with currently installed version.[/yellow]" - ) - prior_source = _read_source_marker(project_path) + # Bundle is the default. Three things can flip us to legacy: + # 1. user passes --legacy-download explicitly + # 2. user passes --pre (no notion of bundle pre-releases) + # 3. workspace was previously provisioned from legacy and + # user has not overridden the channel effective_legacy = ( - legacy_download - or pre - or (prior_source == _SOURCE_LEGACY and not legacy_download) + legacy_download or pre or prior_source == _SOURCE_LEGACY ) - # If --legacy-download is explicitly passed, honour it. - # If neither flag is passed and no prior marker exists, default to bundle. - if not legacy_download and not pre and prior_source is None: - effective_legacy = False download_and_extract_template( project_path, @@ -4211,9 +4211,8 @@ def update( legacy_download=effective_legacy, ) - from . import _assets as _assets_mod - if effective_legacy or not _assets_mod.available(): - _write_source_marker(project_path, _SOURCE_LEGACY) + # .rpgkit/.source is written by whichever provisioning path + # actually ran (_install_from_bundle / _download_and_extract_release_zip). # Refresh .rpgkit/config.toml only when missing (preserves # user customisations on re-update). From f5aad31095531852a17a9806d94d0f868311aba7 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Tue, 19 May 2026 09:39:36 +0800 Subject: [PATCH 04/31] =?UTF-8?q?fix(rpgkit):=20third-pass=20review=20?= =?UTF-8?q?=E2=80=94=20workspace=20config=20commit=20+=20P4=20sed-safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more bugs surfaced in a deeper review of the 0.1.3 plumbing. All fixes are local; existing test suite still shows the same 11 pre-existing failures from main (no regression). 1. .rpgkit/config.toml was still gitignored in user workspaces. 1c47c1d but the gitignore content that rpgkit init/update WRITES into user workspaces (_GITIGNORE_RPGKIT_COMMON) did not. As a result, the config a team would want to commit was still hidden from git. Fixed by adding the un-ignore line to the embedded block. 2. LLMClient.generate_with_memory() silently swallowed the 'AI CLI not configured' RuntimeError and returned None. The caller then sees a generic LLM failure with no hint to run rpgkit init. Now pre-validates self.tool and re-raises the configuration error; genuine LLM-call failures continue to surface as None as before. 3. P4 (release-zip baked-in fallback) was broken by sed. The new resolver introduced two module-level constants: _PLACEHOLDER_LITERAL = '' _BAKED_IN_VALUE = _PLACEHOLDER_LITERAL The release-zip CI runs 'sed s|||g' on every script file, which would have rewritten BOTH constants to the same substituted value, making the _BAKED_IN_VALUE != _PLACEHOLDER_LITERAL guard tautologically false and the P4 fallback effectively dead. Now _PLACEHOLDER_LITERAL is built with string concatenation ("<" + "AI_CLI_CMD" + ">") so sed leaves it alone and the guard does the right thing on legacy-download workspaces. Plus: * Tightened --pre help text in both init() and update() to say 'Implies --legacy-download', matching the new dispatcher behaviour. * Updated the two gitignore-related tests in tests/test_hooks_install.py to count exact lines rather than substrings — the embedded block now contains both '.rpgkit/' and '!.rpgkit/config.toml', so the previous substring count would always be 2. This is the only test edit introduced by the 0.1.3 work. --- RPG-Kit/scripts/common/llm_client.py | 27 +++++++++++++++++++++++++-- RPG-Kit/src/rpgkit_cli/__init__.py | 15 +++++++++++++-- RPG-Kit/tests/test_hooks_install.py | 16 ++++++++++++---- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/RPG-Kit/scripts/common/llm_client.py b/RPG-Kit/scripts/common/llm_client.py index abafee3..abf6e93 100644 --- a/RPG-Kit/scripts/common/llm_client.py +++ b/RPG-Kit/scripts/common/llm_client.py @@ -59,12 +59,17 @@ def _set_pdeathsig() -> None: # so unit tests and tools that construct an LLMClient without intending # to invoke the LLM continue to work. -_PLACEHOLDER_LITERAL = "" +# Sentinel value used to detect whether the release-zip CI substituted +# the baked-in value. Built programmatically (string concatenation) +# so that ``sed s||...|g`` — the CI's substitution invocation, +# see .github/workflows/scripts/rpgkit/create-release-packages.sh — does +# not also rewrite this line and break the comparison below. +_PLACEHOLDER_LITERAL = "<" + "AI_CLI_CMD" + ">" # Module-level default — the release-zip CI replaces this exact literal # with the chosen agent's command at packaging time. Bundle installs # leave it unchanged and rely on P2/P3 to supply the value. -_BAKED_IN_VALUE = _PLACEHOLDER_LITERAL +_BAKED_IN_VALUE = "" def _load_ai_cli_cmd() -> str: @@ -562,7 +567,25 @@ def generate_with_memory( Returns: LLM response text, or None if all retries failed. + + Raises: + RuntimeError: only when ``self.tool`` is not configured. + Genuine LLM-call failures (subprocess errors, timeouts, + bad responses) are swallowed and surface as ``None``; + missing configuration is an unrecoverable user-action + error and must not be silently masked. """ + # Eagerly surface the "AI CLI not configured" condition. This is + # not a transient failure that ``None`` should represent — the + # user needs an actionable error. Genuine LLM failures continue + # to be caught below. + if not self.tool or self.tool == _PLACEHOLDER_LITERAL: + raise RuntimeError( + "AI CLI command not configured. Run " + "`rpgkit init --ai ` in this workspace, or set the " + "RPGKIT_AI_CLI_CMD environment variable." + ) + prompt = self._flatten_memory(memory) try: return self.generate( diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 7acee86..dccbb17 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -715,6 +715,9 @@ def _upgrade_command(method: str) -> list[str] | None: _GITIGNORE_RPGKIT_COMMON = """\ # Runtime workspace (logs, generated data, trajectory) .rpgkit/ +# but DO track the workspace AI config so collaborators see the same +# default — see docs/configuration.md +!.rpgkit/config.toml # Codegen dev environments .venv_dev/ @@ -3516,7 +3519,11 @@ def init( pre: bool = typer.Option( False, "--pre", - help="Download the latest pre-release (dev build) instead of the latest stable release", + help=( + "Download the latest pre-release (dev build) from GitHub. " + "Implies --legacy-download since bundle mode has no notion " + "of pre-release builds." + ), ), no_mcp: bool = typer.Option( False, @@ -4006,7 +4013,11 @@ def update( pre: bool = typer.Option( False, "--pre", - help="Download the latest pre-release (dev build) instead of the latest stable release", + help=( + "Download the latest pre-release (dev build) from GitHub. " + "Implies --legacy-download since bundle mode has no notion " + "of pre-release builds." + ), ), no_mcp: bool = typer.Option( False, diff --git a/RPG-Kit/tests/test_hooks_install.py b/RPG-Kit/tests/test_hooks_install.py index 63f0d07..9b9df2b 100644 --- a/RPG-Kit/tests/test_hooks_install.py +++ b/RPG-Kit/tests/test_hooks_install.py @@ -557,8 +557,11 @@ def test_setup_gitignore_is_idempotent(tmp_path): assert first == second # second call is a no-op # No duplicate RPG-Kit header assert second.count(rpgkit_cli._GITIGNORE_RPGKIT_HEADER) == 1 - # No duplicate .rpgkit/ entry - assert second.count(".rpgkit/") == 1 + # No duplicate .rpgkit/ directory entry. Count actual lines (after + # stripping) because the appended block also contains + # `!.rpgkit/config.toml` which holds .rpgkit/ as a substring. + lines = [l.strip() for l in second.splitlines()] + assert lines.count(".rpgkit/") == 1 def test_setup_gitignore_partial_existing_rules_only_appends_missing(tmp_path): @@ -567,8 +570,13 @@ def test_setup_gitignore_partial_existing_rules_only_appends_missing(tmp_path): (tmp_path / ".gitignore").write_text(".rpgkit/\n") rpgkit_cli._setup_gitignore(tmp_path, "copilot") content = (tmp_path / ".gitignore").read_text() - # .rpgkit/ must NOT be duplicated - assert content.count(".rpgkit/") == 1 + # .rpgkit/ directory entry must NOT be duplicated. Compare exact + # lines (after stripping) because the appended block also contains + # `!.rpgkit/config.toml` which holds .rpgkit/ as a substring. + lines = [l.strip() for l in content.splitlines()] + assert lines.count(".rpgkit/") == 1 + # The new managed config.toml un-ignore line is present + assert "!.rpgkit/config.toml" in lines # Missing rules are now present assert ".vscode/mcp.json" in content assert ".github/agents/" in content From 28ee7523908ba0145fef3d7179d2767aa63c9350 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Tue, 19 May 2026 10:13:19 +0800 Subject: [PATCH 05/31] feat(rpgkit): version command compares local vs latest, shows upgrade hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `rpgkit version` listed both the local CLI version and the latest GitHub release tag but did NOT tell the user which was which or whether action was needed. Combined with `uv tool upgrade rpgkit-cli` silently printing 'Nothing to upgrade' (with no version context), users had to mentally diff two version strings to figure out their status. Now the output adds: * 'Latest Release' (was 'Template Version' — clearer naming). * 'Status' row with one of: - [green]up to date[/] local == remote - [yellow]outdated → X.Y.Z[/] local < remote - [cyan]ahead of release (X.Y.Z)[/] local > remote (dev build) - [yellow]offline[/] GitHub query failed * When relevant, a second panel ('Upgrade tip') with actionable advice: - outdated → list of upgrade commands (uv / pipx / pip) + reminder to follow up with `rpgkit update` in each existing workspace. - ahead → reassure the user nothing is broken (dev build). - offline → show the underlying error and suggest retrying online. Version comparison goes through packaging.version.Version so PEP 440 pre-release / dev / post suffixes (eg 0.1.4.dev0, 0.1.3rc1, 0.1.3.post1) sort correctly. Falls back to plain comparison when packaging is unavailable. No new dependencies (packaging ships with setuptools / pip). Test baseline unchanged: 883 passed, 11 pre-existing failures. --- RPG-Kit/src/rpgkit_cli/__init__.py | 79 +++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 6 deletions(-) diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index dccbb17..f6c2c54 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -4360,7 +4360,12 @@ def check(): @app.command() def version(): - """Display version and system information.""" + """Display version and system information. + + Also fetches the latest release tag from GitHub and reports whether + the locally installed CLI is up to date, behind, or ahead (dev + build). Network failures are swallowed and surface as "offline". + """ show_banner() # Get CLI version from package metadata @@ -4382,8 +4387,9 @@ def version(): # Fetch latest template release version repo_owner, repo_name = _get_repo_info() - template_version = "unknown" + latest_version = "unknown" release_date = "unknown" + fetch_error: str | None = None try: release_data = _fetch_latest_rpgkit_release( @@ -4392,7 +4398,7 @@ def version(): client, timeout=10, ) - template_version = _format_rpgkit_version(release_data.get("tag_name", "unknown")) + latest_version = _format_rpgkit_version(release_data.get("tag_name", "unknown")) release_date = release_data.get("published_at", "unknown") if release_date != "unknown": # Format the date nicely @@ -4401,16 +4407,65 @@ def version(): release_date = dt.strftime("%Y-%m-%d") except Exception: pass - except Exception: - pass + except Exception as exc: + fetch_error = str(exc).splitlines()[0] if str(exc) else type(exc).__name__ + + # ------------------------------------------------------------------ + # Compute the status hint: up-to-date / outdated / ahead / offline. + # Uses ``packaging.version`` (stdlib-ish — ships with setuptools and + # is a transitive dep of pip itself) so PEP 440 pre-release / dev + # suffixes are compared correctly. Falls back to a plain string + # comparison when ``packaging`` is unavailable. + # ------------------------------------------------------------------ + status_label = "[dim]unknown[/dim]" + status_hint: str | None = None + + if fetch_error is not None: + status_label = "[yellow]offline[/yellow]" + status_hint = ( + f"Could not query GitHub for the latest release: {fetch_error}. " + "Local install is still usable; rerun `rpgkit version` when " + "you have network access to compare." + ) + elif cli_version != "unknown" and latest_version != "unknown": + try: + from packaging.version import Version as _Ver + + local_v = _Ver(cli_version) + remote_v = _Ver(latest_version) + except Exception: + local_v = cli_version + remote_v = latest_version + + if local_v == remote_v: + status_label = "[green]up to date[/green]" + elif local_v < remote_v: + status_label = f"[yellow]outdated → {latest_version}[/yellow]" + status_hint = ( + f"A newer release ([cyan]{latest_version}[/cyan]) is " + f"available. Upgrade with one of:\n" + f" [cyan]uv tool upgrade rpgkit-cli[/cyan]\n" + f" [cyan]pipx upgrade rpgkit-cli[/cyan]\n" + f" [cyan]pip install -U rpgkit-cli[/cyan]\n" + f"After upgrading, run [cyan]rpgkit update[/cyan] in each " + f"existing workspace to apply the new prompts." + ) + else: + status_label = f"[cyan]ahead of release ({latest_version})[/cyan]" + status_hint = ( + f"Local CLI ({cli_version}) is newer than the latest " + f"published release ({latest_version}) — typically a dev " + f"build from git. No action needed." + ) info_table = Table(show_header=False, box=None, padding=(0, 2)) info_table.add_column("Key", style="cyan", justify="right") info_table.add_column("Value", style="white") info_table.add_row("CLI Version", cli_version) - info_table.add_row("Template Version", template_version) + info_table.add_row("Latest Release", latest_version) info_table.add_row("Released", release_date) + info_table.add_row("Status", status_label) info_table.add_row("", "") info_table.add_row("Python", platform.python_version()) info_table.add_row("Platform", platform.system()) @@ -4425,6 +4480,18 @@ def version(): ) console.print(panel) + if status_hint: + console.print() + console.print( + Panel( + status_hint, + title="[bold]Upgrade tip[/bold]", + border_style="yellow" + if "outdated" in status_label or "offline" in status_label + else "cyan", + padding=(1, 2), + ) + ) console.print() From 4aa13596aebce70fad114418a18aaffb93761c8d Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Tue, 19 May 2026 16:50:01 +0800 Subject: [PATCH 06/31] feat(cli): route MCP and git hooks via global rpgkit command (Batch A) Plan 02 Batch A: build dispatcher infrastructure and convert MCP + hooks to use the globally-installed rpgkit CLI rather than workspace-local script copies. - Add 'rpgkit script [args...]' dispatcher with --list/--where. - Add 'rpgkit-mcp' console script (rpgkit_cli.entries:mcp_main). - _assets: add list_scripts() + dev-mode fallback to repo scripts/. - mcp_server.py: extract main() function for console-script reuse. - Rewrite MCP config writer: command='rpgkit-mcp', no absolute paths. - Rewrite pre-commit / post-merge / post-commit / Claude SessionStart hooks to invoke 'rpgkit script update_graphs.py ...', with a PATH fallback line for GUI-launched commits. - PATH self-check at end of 'rpgkit init' (warns when rpgkit-mcp missing from PATH). - Update test_hooks_install.py assertions for the new contract. - Batch B (templates + drop workspace scripts copy) to follow. Refs: plans/02-route-scripts-via-cli.md (local) --- RPG-Kit/pyproject.toml | 1 + RPG-Kit/scripts/mcp_server.py | 16 +- RPG-Kit/src/rpgkit_cli/__init__.py | 243 ++++++++++++++++++++++------ RPG-Kit/src/rpgkit_cli/_assets.py | 97 +++++++++-- RPG-Kit/src/rpgkit_cli/entries.py | 44 +++++ RPG-Kit/tests/test_hooks_install.py | 34 ++-- 6 files changed, 365 insertions(+), 70 deletions(-) create mode 100644 RPG-Kit/src/rpgkit_cli/entries.py diff --git a/RPG-Kit/pyproject.toml b/RPG-Kit/pyproject.toml index 2ed39ec..fc5238b 100644 --- a/RPG-Kit/pyproject.toml +++ b/RPG-Kit/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ [project.scripts] rpgkit = "rpgkit_cli:main" +rpgkit-mcp = "rpgkit_cli.entries:mcp_main" [project.urls] Repository = "https://github.com/microsoft/RPG-ZeroRepo" diff --git a/RPG-Kit/scripts/mcp_server.py b/RPG-Kit/scripts/mcp_server.py index 8ae8d01..d0cb782 100644 --- a/RPG-Kit/scripts/mcp_server.py +++ b/RPG-Kit/scripts/mcp_server.py @@ -377,10 +377,18 @@ def list_rpg_tree( # --------------------------------------------------------------------------- -# Entry point: python .rpgkit/scripts/mcp_server.py [--rpg-file PATH] +# Entry point: ``rpgkit-mcp`` console script (via rpgkit_cli.entries:mcp_main) +# or direct ``python /mcp_server.py [--rpg-file PATH]`` for +# debugging. # --------------------------------------------------------------------------- -if __name__ == "__main__": +def main() -> None: + """Run the MCP server over stdio. + + Used by both the ``rpgkit-mcp`` console-script entry (which sets up + ``sys.path`` then imports and calls this function) and the direct + ``python mcp_server.py`` invocation under ``__main__``. + """ rpg_path = _resolve_rpg_path() # NOTE: do NOT sys.exit when the file is missing. The MCP transport # must stay up so the client can actually receive the @@ -396,3 +404,7 @@ def list_rpg_tree( server = create_mcp_server(rpg_file=rpg_path) server.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index f6c2c54..2baed2d 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -1434,37 +1434,26 @@ def _generate_mcp_config( ) -> None: """Generate MCP server configuration for the selected AI assistant. - Both Claude and VS Code Copilot launch the MCP server via the current - Python interpreter (``sys.executable``) running - ``/.rpgkit/scripts/mcp_server.py`` — this guarantees the - interpreter that has ``rpgkit-cli``'s dependencies (mcp, rapidfuzz, …) - installed is used to host the server. + Both Claude and VS Code Copilot launch the MCP server via the + ``rpgkit-mcp`` console script installed alongside ``rpgkit-cli``. + This keeps the config portable across machines (no absolute paths + to a workspace-local copy) and ensures the server always runs + against the bundled scripts that match the installed CLI version. - Claude: ``.mcp.json`` (key ``mcpServers.rpg-tools``) - Copilot: ``.vscode/mcp.json`` (key ``servers.rpg-tools``, VS Code 1.102+ standard layout) - Generated paths are absolute and machine-specific; the corresponding - files are ignored via :func:`_setup_gitignore` (called earlier in the - init flow), not by this function. + The ``rpgkit-mcp`` command must be on ``PATH``. ``rpgkit init`` + emits a warning at the end of the run when it isn't, so MCP + clients fail with a clear cause rather than the opaque + ``Connection closed`` error. """ - # Resolve absolute paths up-front so we never write a stale/relative path. project_path = project_path.resolve() - server_script = (project_path / ".rpgkit" / "scripts" / "mcp_server.py").resolve() - - if not server_script.is_file(): - # Should not happen — extraction step runs before us — but bail out - # cleanly instead of writing a config that would fail at runtime. - msg = f"mcp_server.py not found at {server_script}" - if tracker: - tracker.error("mcp", msg) - else: - console.print(f"[yellow]Warning: {msg}[/yellow]") - return mcp_server_config = { - "command": sys.executable, - "args": [str(server_script)], + "command": "rpgkit-mcp", + "args": [], } try: @@ -2057,13 +2046,10 @@ def _install_claude_hooks(project_path: Path) -> None: if settings_path.exists(): shutil.copy2(settings_path, settings_dir / "settings.json.bak") - # Shell form: ``command`` is passed to ``sh -c``. Use shlex.quote so - # paths containing spaces or special characters survive shell - # tokenisation (json.dumps is JSON-safe but not shell-safe). - update_script = shlex.quote( - str((project_path / ".rpgkit" / "scripts" / "update_graphs.py").resolve()) - ) - python = shlex.quote(sys.executable) + # The command is executed by Claude Code via ``sh -c``, so we inline + # the same PATH-fallback used by git hooks (see _HOOK_PATH_FALLBACK). + # Use ``;`` rather than ``&&`` so the rpgkit call always runs after + # the (possibly no-op) PATH adjustment. marker = "update_graphs.py" # used for idempotent dedupe across upgrades rpg_session_entry = { @@ -2072,7 +2058,8 @@ def _install_claude_hooks(project_path: Path) -> None: { "type": "command", "command": ( - f"{python} {update_script} status 2>/dev/null" + f"{_HOOK_PATH_FALLBACK}; " + "rpgkit script update_graphs.py status 2>/dev/null" " || echo '[RPG-Kit] RPG status unavailable'" ), "timeout": 10, @@ -2306,6 +2293,27 @@ def _strip_hook_block( return "\n".join(out) +# --------------------------------------------------------------------------- +# PATH fallback for hook bodies +# --------------------------------------------------------------------------- +# +# Hooks invoke ``rpgkit`` (the globally-installed CLI) rather than a +# workspace-local script copy. When the hook is triggered from a GUI +# editor's source-control panel (VS Code, IntelliJ, GitHub Desktop, ...) +# the process environment may not include the user's shell PATH, so +# ``rpgkit`` is unresolvable and the hook silently fails. +# +# This snippet is prepended to every hook body. When ``rpgkit`` is +# already on PATH (terminal invocations) the test short-circuits and +# the ``export`` is skipped — zero overhead. When it isn't, we +# prepend ``$HOME/.local/bin`` which is ``uv tool install``'s default +# bin directory. +_HOOK_PATH_FALLBACK = ( + 'command -v rpgkit >/dev/null 2>&1 || ' + 'export PATH="$HOME/.local/bin:$PATH"' +) + + def _install_hook_snippet( hooks_dir: Path, hook_name: str, @@ -2371,19 +2379,23 @@ def _install_git_pre_commit_hook(project_path: Path) -> bool: The hook passes ``--staged-only`` so only files the user ``git add``'d contribute to the diff — working-tree-but-not-staged changes are out of scope for the imminent commit. + + The hook invokes the globally-installed ``rpgkit`` CLI rather than + a workspace-local script copy. A PATH fallback prepends + ``$HOME/.local/bin`` (uv tool install's default bin dir) so the + hook works when triggered from GUI editors (VS Code / IntelliJ + source-control panels) whose process environment may not include + the user's shell PATH. """ hooks_dir = _resolve_git_hooks_dir(project_path) if hooks_dir is None: return False - python = shlex.quote(sys.executable) - update_script = shlex.quote( - str((project_path / ".rpgkit" / "scripts" / "update_graphs.py").resolve()) - ) marker = "# RPG-Kit: incremental RPG sync on commit" body = ( f"{marker}\n" - f"{python} {update_script} sync --staged-only 2>/dev/null || true" + f"{_HOOK_PATH_FALLBACK}\n" + f"rpgkit script update_graphs.py sync --staged-only 2>/dev/null || true" ) # Legacy: pre-Step-3 pre-commit shipped a 2-line snippet under the # marker below. Removed on upgrade so users don't end up running @@ -2413,14 +2425,11 @@ def _install_git_post_merge_hook(project_path: Path) -> bool: if hooks_dir is None: return False - python = shlex.quote(sys.executable) - update_script = shlex.quote( - str((project_path / ".rpgkit" / "scripts" / "update_graphs.py").resolve()) - ) marker = "# RPG-Kit: incremental RPG sync after merge / pull" body = ( f"{marker}\n" - f"{python} {update_script} sync 2>/dev/null || true" + f"{_HOOK_PATH_FALLBACK}\n" + f"rpgkit script update_graphs.py sync 2>/dev/null || true" ) # post-merge was introduced with the sentinel-block design already # in mind, so no legacy migration is needed here. @@ -2458,10 +2467,6 @@ def _install_git_post_commit_hook(project_path: Path) -> bool: if hooks_dir is None: return False - python = shlex.quote(sys.executable) - update_script = shlex.quote( - str((project_path / ".rpgkit" / "scripts" / "update_graphs.py").resolve()) - ) log_file = shlex.quote( str((project_path / ".rpgkit" / "logs" / "update_rpg.log").resolve()) ) @@ -2472,8 +2477,9 @@ def _install_git_post_commit_hook(project_path: Path) -> bool: workspace_dir = shlex.quote(str(project_path.resolve())) body = ( f"{marker}\n" + f"{_HOOK_PATH_FALLBACK}\n" # Phase 1: synchronous meta.git advance - f"{python} {update_script} sync 2>/dev/null || true\n" + f"rpgkit script update_graphs.py sync 2>/dev/null || true\n" # Phase 2: background full RPG update. # # Lock semantics (v4): @@ -2507,7 +2513,7 @@ def _install_git_post_commit_hook(project_path: Path) -> bool: f"if mkdir {lock_file} 2>/dev/null; then\n" f" nohup env -u GIT_INDEX_FILE -u GIT_DIR " f'sh -c "cd {workspace_dir}; sleep 2; ' - f'{python} {update_script} update-rpg --json >> {log_file} 2>&1; ' + f'rpgkit script update_graphs.py update-rpg --json >> {log_file} 2>&1; ' f'rmdir {lock_file}" /dev/null 2>&1 &\n' f"fi" ) @@ -3857,6 +3863,39 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") + # PATH self-check: hooks and MCP rely on ``rpgkit`` / ``rpgkit-mcp`` + # being resolvable. If they aren't on PATH, the user will hit + # opaque failures from git hooks and MCP clients later — surface + # the actionable hint now. + import shutil as _shutil + if _shutil.which("rpgkit-mcp") is None or _shutil.which("rpgkit") is None: + reinstall_cmd: Optional[list[str]] = _upgrade_command(_detect_install_method()) + # ``--force`` reinstalls in place which fixes most PATH issues + # caused by partial installs / corrupted shim links. + if reinstall_cmd and reinstall_cmd[:3] == ["uv", "tool", "upgrade"]: + reinstall_hint = "uv tool install rpgkit-cli --force" + elif reinstall_cmd and reinstall_cmd[:2] == ["pipx", "upgrade"]: + reinstall_hint = "pipx install rpgkit-cli --force" + elif reinstall_cmd: + reinstall_hint = " ".join(reinstall_cmd) + else: + reinstall_hint = "uv tool install rpgkit-cli --force # or your installer's equivalent" + console.print() + path_panel = Panel( + "[yellow]Warning:[/yellow] [cyan]rpgkit[/cyan] / [cyan]rpgkit-mcp[/cyan] " + "not found on PATH.\n\n" + "Git hooks and the MCP server invoke these commands; they will " + "fail until PATH is fixed.\n\n" + "[bold]Fix:[/bold]\n" + " - Linux/macOS: add [cyan]~/.local/bin[/cyan] to PATH in your shell rc\n" + " - Windows: add [cyan]%USERPROFILE%\\.local\\bin[/cyan] to PATH\n" + f" - Or reinstall: [cyan]{reinstall_hint}[/cyan]", + title="[red]PATH check[/red]", + border_style="yellow", + padding=(1, 2), + ) + console.print(path_panel) + # Show git error details if initialization failed if git_error_message: console.print() @@ -4317,6 +4356,118 @@ def update( ) +@app.command( + context_settings={ + "allow_extra_args": True, + "ignore_unknown_options": True, + # Disable click's auto-help so ``--help`` is forwarded to the + # target script. Use ``rpgkit script`` (no args) or + # ``rpgkit --help script`` to see this command's own help. + "help_option_names": [], + }, +) +def script( + ctx: typer.Context, + relpath: Optional[str] = typer.Argument( + None, + help="Script path relative to the packaged scripts directory " + "(e.g. 'smoke_test.py' or 'rpg_edit/validate.py'). " + "The '.py' suffix is optional.", + ), + list_all: bool = typer.Option( + False, + "--list", + help="List all available scripts and exit.", + ), + where: Optional[str] = typer.Option( + None, + "--where", + metavar="NAME", + help="Print the absolute filesystem path of NAME and exit.", + ), +) -> None: + """Execute a bundled RPG-Kit pipeline script. + + All arguments after ```` are forwarded verbatim to the + target script. Standard input/output/error are inherited so the + child's behaviour matches direct invocation. + + Examples:: + + rpgkit script smoke_test.py --json + rpgkit script rpg_edit/validate.py + rpgkit script --list + rpgkit script --where mcp_server.py + """ + from . import _assets + + if list_all: + for name in _assets.list_scripts(): + console.print(name) + raise typer.Exit(0) + + if where is not None: + path = _resolve_script_path(where) + if path is None: + console.print(f"[red]script not found: {where}[/red]") + raise typer.Exit(1) + # Print plain path (no markup) so it pipes cleanly into $(...) + print(str(path)) + raise typer.Exit(0) + + if not relpath: + console.print( + "[red]error:[/red] missing script path. " + "Use [cyan]rpgkit script --list[/cyan] to see available scripts." + ) + raise typer.Exit(2) + + path = _resolve_script_path(relpath) + if path is None: + console.print(f"[red]script not found: {relpath}[/red]") + raise typer.Exit(1) + + # Build child env: inherit, plus disable .pyc writes so the read-mostly + # tool-venv install dir doesn't accumulate __pycache__ noise. + env = os.environ.copy() + env.setdefault("PYTHONDONTWRITEBYTECODE", "1") + + cmd = [sys.executable, str(path), *ctx.args] + proc = subprocess.run(cmd, env=env) + raise typer.Exit(proc.returncode) + + +def _resolve_script_path(relpath: str) -> Optional[Path]: + """Resolve ``relpath`` against the packaged scripts dir. + + Rejects path-traversal and absolute paths; appends ``.py`` when no + suffix is given. Returns ``None`` if the resolved path is not a + regular file inside :func:`_assets.scripts_dir`. + """ + from . import _assets + + # Normalise separators for cross-platform invocation + rel = relpath.replace("\\", "/") + # Security: reject parent-traversal and absolute paths + if rel.startswith("/") or ".." in rel.split("/"): + return None + p = Path(rel) + if p.is_absolute(): + return None + if p.suffix == "": + p = p.with_suffix(".py") + root = _assets.scripts_dir() + candidate = (root / p).resolve() + try: + candidate.relative_to(root.resolve()) + except ValueError: + # Resolved outside the scripts root (e.g. via symlink) — refuse + return None + if not candidate.is_file(): + return None + return candidate + + @app.command() def check(): """Check that all required tools are installed.""" diff --git a/RPG-Kit/src/rpgkit_cli/_assets.py b/RPG-Kit/src/rpgkit_cli/_assets.py index e150556..09e3528 100644 --- a/RPG-Kit/src/rpgkit_cli/_assets.py +++ b/RPG-Kit/src/rpgkit_cli/_assets.py @@ -46,28 +46,101 @@ def core_pack_root() -> Path: return Path(str(files("rpgkit_cli").joinpath("core_pack"))) +def _dev_scripts_dir() -> Path | None: + """Locate the repo-root ``scripts/`` directory for editable/dev installs. + + When ``rpgkit-cli`` is installed in editable mode (``pip install -e .`` + or ``uv run rpgkit ...`` from the source tree), hatch's + ``force-include`` does not populate ``rpgkit_cli/core_pack/``. In + that case we fall back to the live source at ``/scripts/``, + which sits two levels above this file:: + + / + src/rpgkit_cli/_assets.py ← __file__ + scripts/ ← target + """ + here = Path(__file__).resolve() + # src/rpgkit_cli/_assets.py → repo = parents[2] + if len(here.parents) >= 3: + candidate = here.parents[2] / "scripts" + if candidate.is_dir(): + return candidate + return None + + +def _dev_commands_dir() -> Path | None: + """Counterpart to :func:`_dev_scripts_dir` for slash-command templates.""" + here = Path(__file__).resolve() + if len(here.parents) >= 3: + candidate = here.parents[2] / "templates" / "commands" + if candidate.is_dir(): + return candidate + return None + + def available() -> bool: - """True iff a usable bundle exists in the installed package. + """True iff a usable scripts source exists. - Returns ``False`` for editable installs (where ``force-include`` did - not run) and for any other situation where the bundle is missing - or incomplete. Callers use this to decide whether to fall back to - the legacy GitHub-release-zip download path. + Returns ``True`` when either the wheel-bundled ``core_pack/scripts/`` + OR the dev-mode ``/scripts/`` is present. Used to decide + whether the bundle path is viable; callers fall back to the legacy + GitHub-release-zip download path otherwise. """ - root = core_pack_root() - return root.is_dir() and (root / "scripts").is_dir() + return scripts_dir().is_dir() def scripts_dir() -> Path: - """Directory containing the bundled RPG-Kit pipeline scripts.""" - return core_pack_root() / "scripts" + """Directory containing the RPG-Kit pipeline scripts. + + Resolution order: + 1. Wheel bundle: ``/rpgkit_cli/core_pack/scripts/`` + 2. Dev/editable fallback: ``/scripts/`` + + Falls back to the wheel path even when missing so error messages + contain a stable, recognisable location. + """ + bundled = core_pack_root() / "scripts" + if bundled.is_dir(): + return bundled + dev = _dev_scripts_dir() + if dev is not None: + return dev + return bundled # may not exist; caller decides how to surface def commands_dir() -> Path: - """Directory containing the bundled slash-command templates.""" - return core_pack_root() / "commands" + """Directory containing the slash-command templates. + + Same resolution order as :func:`scripts_dir`. + """ + bundled = core_pack_root() / "commands" + if bundled.is_dir(): + return bundled + dev = _dev_commands_dir() + if dev is not None: + return dev + return bundled def mcp_server_path() -> Path: - """Convenience: path to the bundled MCP server entry script.""" + """Convenience: path to the MCP server entry script.""" return scripts_dir() / "mcp_server.py" + + +def list_scripts() -> list[str]: + """Return all script relative paths (POSIX-style) under :func:`scripts_dir`. + + Filters to ``.py`` files only, skips ``__pycache__`` directories, + and sorts alphabetically. Used by ``rpgkit script --list``. + """ + root = scripts_dir() + if not root.is_dir(): + return [] + out: list[str] = [] + for p in root.rglob("*.py"): + if "__pycache__" in p.parts: + continue + out.append(p.relative_to(root).as_posix()) + out.sort() + return out + diff --git a/RPG-Kit/src/rpgkit_cli/entries.py b/RPG-Kit/src/rpgkit_cli/entries.py new file mode 100644 index 0000000..152e04e --- /dev/null +++ b/RPG-Kit/src/rpgkit_cli/entries.py @@ -0,0 +1,44 @@ +"""Console-script entries for ``rpgkit-cli``. + +Currently provides: + +* :func:`mcp_main` — the ``rpgkit-mcp`` console script. Sets up + ``sys.path`` so that the bundled ``scripts/`` directory is importable, + then hands off to ``mcp_server.main()``. + +This module is deliberately tiny and stdout-silent — MCP uses stdio as +its transport, so writing anything to stdout from import-time code would +corrupt the JSON-RPC stream. All diagnostics go to stderr. +""" + +from __future__ import annotations + + +def mcp_main() -> None: + """Console-script entry for MCP clients (stdio transport).""" + import os + import sys + + from . import _assets + + os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1") + + scripts_dir = _assets.scripts_dir() + if scripts_dir is None or not scripts_dir.is_dir(): + sys.stderr.write( + "rpgkit-mcp: packaged scripts directory unavailable. " + "Try reinstalling: `uv tool install rpgkit-cli --force`.\n" + ) + sys.exit(2) + + # Make ``mcp_server`` and its sibling packages (``common``, ``rpg``) + # importable from the packaged scripts dir. + sys.path.insert(0, str(scripts_dir)) + + try: + from mcp_server import main as _mcp_server_main # type: ignore[import-not-found] + except Exception as exc: # pragma: no cover - import-time failure surface + sys.stderr.write(f"rpgkit-mcp: failed to import mcp_server: {exc}\n") + sys.exit(3) + + _mcp_server_main() diff --git a/RPG-Kit/tests/test_hooks_install.py b/RPG-Kit/tests/test_hooks_install.py index 9b9df2b..2b2aa08 100644 --- a/RPG-Kit/tests/test_hooks_install.py +++ b/RPG-Kit/tests/test_hooks_install.py @@ -61,14 +61,24 @@ def test_install_claude_hooks_writes_session_start(project): session_start = data["hooks"]["SessionStart"] assert isinstance(session_start, list) and len(session_start) == 1 cmd = session_start[0]["hooks"][0]["command"] - assert "update_graphs.py" in cmd + # Hook now invokes the global ``rpgkit`` CLI; no embedded sys.executable. + assert "rpgkit script update_graphs.py status" in cmd + # PATH fallback for GUI-launched session starts (VS Code / IDE git UI). + assert "command -v rpgkit" in cmd assert cmd.endswith("status 2>/dev/null || echo '[RPG-Kit] RPG status unavailable'") def test_install_claude_hooks_is_idempotent_across_python_upgrades(project, monkeypatch): - """Re-installing with a different ``sys.executable`` must not stack duplicate SessionStart entries: an outdated Python path pointing to a missing interpreter would fail every session start, while still appearing alongside the new entry.""" + """Re-installing must not stack duplicate SessionStart entries. + + Hooks no longer embed ``sys.executable``; they delegate to the + globally-installed ``rpgkit`` CLI. Re-running install therefore + yields the exact same command and must remain a single entry + (not a duplicate per invocation). + """ rpgkit_cli._install_claude_hooks(project) - # Simulate a Python interpreter upgrade (path differs). + # Simulate any environment change that previously affected hook content; + # the new hook body is interpreter-independent so this should be a no-op. monkeypatch.setattr(rpgkit_cli.sys, "executable", "/opt/new-python/bin/python") rpgkit_cli._install_claude_hooks(project) data = json.loads((project / ".claude" / "settings.json").read_text()) @@ -79,15 +89,18 @@ def test_install_claude_hooks_is_idempotent_across_python_upgrades(project, monk ] assert len(rpgkit_entries) == 1 cmd = rpgkit_entries[0]["hooks"][0]["command"] - assert "/opt/new-python/bin/python" in cmd # latest interpreter wins + # Always uses the rpgkit-script form regardless of interpreter path. + assert "rpgkit script update_graphs.py" in cmd + assert "/opt/new-python/bin/python" not in cmd def test_install_claude_hooks_shell_escapes_special_chars(project, monkeypatch): - """Paths with spaces or quotes must survive ``sh -c`` tokenisation. + """Interpreter / workspace paths must not appear in the hook command. - Claude hooks run shell form, so the command field is passed verbatim - to ``sh -c``. We rely on ``shlex.quote`` for safety; json.dumps - would leave bare spaces in paths exposed. + Previously the hook embedded ``sys.executable`` and the workspace + script path, requiring ``shlex.quote`` to survive spaces. The new + hook body invokes the global ``rpgkit`` CLI directly, so paths with + special characters can't end up inside the command string. """ monkeypatch.setattr( rpgkit_cli.sys, "executable", "/path with space/python" @@ -97,8 +110,9 @@ def test_install_claude_hooks_shell_escapes_special_chars(project, monkeypatch): json.loads((project / ".claude" / "settings.json").read_text()) ["hooks"]["SessionStart"][0]["hooks"][0]["command"] ) - # shlex.quote wraps in single quotes on POSIX - assert "'/path with space/python'" in cmd + # No path leakage from the interpreter / workspace location. + assert "/path with space" not in cmd + assert "rpgkit script update_graphs.py" in cmd def test_install_claude_hooks_merges_existing(project): From 190c1abfffff3ea69af733c931fb6576848780e9 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Tue, 19 May 2026 17:17:00 +0800 Subject: [PATCH 07/31] refactor(scripts): route next_action hints via rpgkit script (Batch B-1/2) Plan 02 Batch B step 1+2: introduce cmd_for() helper and rewrite all hardcoded 'python3 .rpgkit/scripts/X.py' references inside the pipeline scripts. - common/paths.py: re-anchor SCRIPTS_DIR to Path(__file__).parent.parent so it points at the actual filesystem location regardless of whether scripts are run from a workspace copy (pre-0.1.3) or the packaged rpgkit_cli/core_pack/scripts/ dir (post-0.1.3). - common/paths.py: add cmd_for(relpath) helper returning the canonical 'rpgkit script ' invocation. - Sweep 12 script files: replace 21 hardcoded invocation strings in next_action messages, inter-script spawn commands, and error hints with cmd_for() calls. Files: init_codebase.py, smoke_test.py, check_skeleton.py, check_code_gen.py, run_batch.py, update_graphs.py, code_gen/result_builders.py, rpg_encoder/run_update_rpg.py, rpg_encoder/run_encode.py, rpg_edit/{validate,review,code}.py. Workspace .rpgkit/scripts/ copy still happens (templates not yet converted); next commit removes both. --- RPG-Kit/scripts/check_code_gen.py | 19 +++---- RPG-Kit/scripts/check_skeleton.py | 4 +- RPG-Kit/scripts/code_gen/result_builders.py | 19 +++---- RPG-Kit/scripts/common/paths.py | 53 ++++++++++++++++--- RPG-Kit/scripts/init_codebase.py | 7 +-- RPG-Kit/scripts/rpg_edit/code.py | 3 +- RPG-Kit/scripts/rpg_edit/review.py | 6 +-- RPG-Kit/scripts/rpg_edit/validate.py | 2 +- RPG-Kit/scripts/rpg_encoder/run_encode.py | 4 +- RPG-Kit/scripts/rpg_encoder/run_update_rpg.py | 2 +- RPG-Kit/scripts/run_batch.py | 5 +- RPG-Kit/scripts/smoke_test.py | 4 +- RPG-Kit/scripts/update_graphs.py | 10 ++-- 13 files changed, 92 insertions(+), 46 deletions(-) diff --git a/RPG-Kit/scripts/check_code_gen.py b/RPG-Kit/scripts/check_code_gen.py index d0d7669..2abb152 100644 --- a/RPG-Kit/scripts/check_code_gen.py +++ b/RPG-Kit/scripts/check_code_gen.py @@ -21,6 +21,7 @@ TASKS_FILE, CODE_GEN_STATE_FILE as STATE_FILE, get_scripts_dir, + cmd_for, REPO_DIR, ) from common.execution_state import load_code_gen_state @@ -155,8 +156,8 @@ def determine_state( "remaining": total_tasks } result["next_action"] = ( - f"Run: python3 {scripts}/init_codebase.py --json to initialize the repository, " - f"then run: python3 {scripts}/run_batch.py --next --json to start the first batch." + f"Run: {cmd_for("init_codebase.py")} --json to initialize the repository, " + f"then run: {cmd_for("run_batch.py")} --next --json to start the first batch." ) result["workflow_hint"] = ( "run_batch.py --next dispatches a sub-agent that autonomously " @@ -262,7 +263,7 @@ def determine_state( result["auto_recovery_error"] = str(e) result["next_action"] = ( f"Tests passed but auto-recovery failed ({e}). " - f"Run: python3 {scripts}/run_batch.py --resume --json to retry." + f"Run: {cmd_for("run_batch.py")} --resume --json to retry." ) return result else: @@ -280,14 +281,14 @@ def determine_state( if phase == "failed": result["next_action"] = ( f"Batch {current_batch_id} has failed. " - f"Run: python3 {scripts}/run_batch.py --retry {current_batch_id} --json " - f"to retry, or python3 {scripts}/run_batch.py --next --json to skip " + f"Run: {cmd_for("run_batch.py")} --retry {current_batch_id} --json " + f"to retry, or {cmd_for("run_batch.py")} --next --json to skip " f"it and move on." ) else: result["next_action"] = ( f"Resume the current batch (phase: {phase}). " - f"Run: python3 {scripts}/run_batch.py --resume --json" + f"Run: {cmd_for("run_batch.py")} --resume --json" ) result["workflow_hint"] = ( "run_batch.py --resume dispatches a sub-agent that autonomously " @@ -307,7 +308,7 @@ def determine_state( result["message"] = f"Ready to continue ({remaining} tasks remaining)" result["next_batch"] = next_batch result["next_action"] = ( - f"Run: python3 {scripts}/run_batch.py --next --json " + f"Run: {cmd_for("run_batch.py")} --next --json " f"to start the next batch." ) result["workflow_hint"] = ( @@ -344,11 +345,11 @@ def determine_state( if not ft_passed: result["next_action"] = ( - f"Run: python3 {scripts}/run_batch.py --final-test --json" + f"Run: {cmd_for("run_batch.py")} --final-test --json" ) elif not gr_passed: result["next_action"] = ( - f"Final test passed. Run: python3 {scripts}/run_batch.py --global-review --json" + f"Final test passed. Run: {cmd_for("run_batch.py")} --global-review --json" ) else: result["next_action"] = ( diff --git a/RPG-Kit/scripts/check_skeleton.py b/RPG-Kit/scripts/check_skeleton.py index 95e5dd4..8c2c692 100644 --- a/RPG-Kit/scripts/check_skeleton.py +++ b/RPG-Kit/scripts/check_skeleton.py @@ -357,9 +357,9 @@ def inspect_state() -> Dict[str, Any]: # Add next_action for clear guidance if type_value == "init": - result["next_action"] = "python3 .rpgkit/scripts/build_skeleton.py --max-iterations 10" + result["next_action"] = "rpgkit script build_skeleton.py --max-iterations 10" elif type_value == "warning": - result["next_action"] = "python3 .rpgkit/scripts/build_skeleton.py --patch" + result["next_action"] = "rpgkit script build_skeleton.py --patch" else: result["next_action"] = "Skeleton is consistent. Proceed to next step." diff --git a/RPG-Kit/scripts/code_gen/result_builders.py b/RPG-Kit/scripts/code_gen/result_builders.py index bb19025..96fb62b 100644 --- a/RPG-Kit/scripts/code_gen/result_builders.py +++ b/RPG-Kit/scripts/code_gen/result_builders.py @@ -17,6 +17,7 @@ from common.execution_state import BatchExecutionState, CodeGenState, load_code_gen_state from common.task_batch import PlannedTask, load_tasks_from_tasks_json +from common.paths import cmd_for def _error(message: str, scripts: str) -> Dict[str, Any]: @@ -24,7 +25,7 @@ def _error(message: str, scripts: str) -> Dict[str, Any]: return { "success": False, "error": message, - "next_action": f"Fix the issue, then run: python3 {scripts}/run_batch.py --next --json", + "next_action": f"Fix the issue, then run: {cmd_for("run_batch.py")} --next --json", } @@ -39,12 +40,12 @@ def _all_done(global_state: CodeGenState, tasks_path: Path, scripts: str) -> Dic msg = f"All batches processed: {completed} completed, {failed} failed out of {total}." next_act = ( f"Some batches failed. You can retry them with: " - f"python3 {scripts}/run_batch.py --retry --json, " - f"or run final validation: python3 {scripts}/run_batch.py --final-test --json" + f"{cmd_for("run_batch.py")} --retry --json, " + f"or run final validation: {cmd_for("run_batch.py")} --final-test --json" ) else: msg = f"All {completed} batches completed successfully!" - next_act = f"Run final validation: python3 {scripts}/run_batch.py --final-test --json" + next_act = f"Run final validation: {cmd_for("run_batch.py")} --final-test --json" return { "success": True, @@ -100,10 +101,10 @@ def _success_result( }, "next_action": ( f"Batch completed. {remaining} tasks remaining. " - f"Run: python3 {scripts}/run_batch.py --next --json" + f"Run: {cmd_for("run_batch.py")} --next --json" if remaining > 0 else - f"All batches done! Run: python3 {scripts}/run_batch.py --final-test --json\n" - f"Then run: python3 {scripts}/run_batch.py --global-review --json" + f"All batches done! Run: {cmd_for("run_batch.py")} --final-test --json\n" + f"Then run: {cmd_for("run_batch.py")} --global-review --json" ), } @@ -146,7 +147,7 @@ def _failure_result( "next_action": ( f"Batch failed after {len(attempts)} attempts. " f"Branch '{batch_state.branch_name}' preserved for inspection. " - f"Retry: python3 {scripts}/run_batch.py --retry {batch_id} --json, " - f"or continue: python3 {scripts}/run_batch.py --next --json" + f"Retry: {cmd_for("run_batch.py")} --retry {batch_id} --json, " + f"or continue: {cmd_for("run_batch.py")} --next --json" ), } diff --git a/RPG-Kit/scripts/common/paths.py b/RPG-Kit/scripts/common/paths.py index 5480f1f..5c0aa3b 100644 --- a/RPG-Kit/scripts/common/paths.py +++ b/RPG-Kit/scripts/common/paths.py @@ -83,21 +83,62 @@ def _find_workspace_root() -> Path: # ============================================================================ -# Scripts Directory (absolute, for embedding in next_action messages) +# Scripts Directory (absolute path on the filesystem) # ============================================================================ +# +# Anchor SCRIPTS_DIR to ``__file__``'s parent so the constant resolves +# correctly regardless of how the scripts were deployed: +# +# * Pre-0.1.3 layout: scripts copied into ``/.rpgkit/scripts/``. +# * Post-0.1.3 layout: scripts live inside the installed wheel at +# ``/rpgkit_cli/core_pack/scripts/`` and are invoked +# via ``rpgkit script `` (see plan 02). +# +# In both cases the surrounding ``common/`` package is at +# ``SCRIPTS_DIR/common/``, so ``Path(__file__).parent.parent`` is the +# scripts root. Callers that need to spawn or sys.path-insert sibling +# code (e.g. ``rpg_edit/impact.py``) get a working path automatically. +# +# For *user-facing hints* embedded in ``next_action`` messages, prefer +# :func:`cmd_for` instead of stringifying ``SCRIPTS_DIR`` — the former +# emits the supported ``rpgkit script `` invocation rather than a +# raw filesystem path the user can't easily re-run. -# Anchor SCRIPTS_DIR to WORKSPACE_ROOT so that paths embedded in -# next_action messages (read by the AI agent) reference the user's -# workspace path — not the symlink target. -SCRIPTS_DIR = WORKSPACE_ROOT / ".rpgkit" / "scripts" +SCRIPTS_DIR = Path(__file__).resolve().parent.parent TOOLS_DIR = SCRIPTS_DIR / "tools" def get_scripts_dir() -> str: - """Get the scripts directory path as string for use in next_action messages.""" + """Return the scripts directory as a string (filesystem path). + + Kept for backward compatibility with code that uses this as a + base path for sibling-script Path/sys.path operations. Do NOT + use this to build invocation strings shown to the user — use + :func:`cmd_for` instead. + """ return str(SCRIPTS_DIR) +def cmd_for(script_relpath: str) -> str: + """Return the canonical ``rpgkit script`` invocation for a script. + + Args: + script_relpath: Path relative to the scripts root, e.g. + ``"run_batch.py"`` or ``"rpg_edit/validate.py"``. Leading + slashes are stripped; ``.py`` suffix is preserved. + + Returns: + A shell-ready string such as ``"rpgkit script run_batch.py"``. + + Use this for any ``next_action`` hint or error message that + suggests the user run a script. After plan 02, the workspace no + longer hosts a ``.rpgkit/scripts/`` copy, so the historic + ``python3 .rpgkit/scripts/X.py`` form would fail; ``rpgkit script + X.py`` works regardless of workspace layout. + """ + return f"rpgkit script {script_relpath.lstrip('/')}" + + # ============================================================================ # .rpgkit Directory Structure (absolute, derived from WORKSPACE_ROOT) # ============================================================================ diff --git a/RPG-Kit/scripts/init_codebase.py b/RPG-Kit/scripts/init_codebase.py index 9f1f843..5ecc73f 100644 --- a/RPG-Kit/scripts/init_codebase.py +++ b/RPG-Kit/scripts/init_codebase.py @@ -37,6 +37,7 @@ FEATURE_BUILD_FILE, CODE_GEN_STATE_FILE as STATE_FILE, get_scripts_dir, + cmd_for, REPO_DIR, ) from common.execution_state import load_code_gen_state, save_code_gen_state @@ -536,7 +537,7 @@ def init_codebase( "initialized_at": state.initialized_at, "suggestion": "Run run_batch.py to start codegen", "next_action": ( - f"Already initialized. Run: python3 {scripts}/run_batch.py --next --json " + f"Already initialized. Run: {cmd_for("run_batch.py")} --next --json " f"to start the next batch." ) } @@ -599,7 +600,7 @@ def init_codebase( "gitignore_created": False, "base_class_files": 0, "next_action": ( - f"Codebase already set up. Run: python3 {scripts}/run_batch.py --next --json " + f"Codebase already set up. Run: {cmd_for("run_batch.py")} --next --json " f"to start the first batch." ) } @@ -632,7 +633,7 @@ def init_codebase( "commit_hash": commit_hash, "message": "Repository initialized successfully" if not dry_run else "Dry run complete", "next_action": ( - f"Codebase initialized. Run: python3 {get_scripts_dir()}/run_batch.py --next --json " + f"Codebase initialized. Run: {cmd_for("run_batch.py")} --next --json " f"to start the first batch." ) if not dry_run else "Dry run complete. Re-run without --dry-run to apply changes." } diff --git a/RPG-Kit/scripts/rpg_edit/code.py b/RPG-Kit/scripts/rpg_edit/code.py index 7b67bbc..843b6b0 100644 --- a/RPG-Kit/scripts/rpg_edit/code.py +++ b/RPG-Kit/scripts/rpg_edit/code.py @@ -44,6 +44,7 @@ DATA_DIR, WORKSPACE_ROOT, REPO_DIR, + cmd_for, ) from common.logging_setup import setup_file_logging # noqa: E402 @@ -174,7 +175,7 @@ def _build_validation_cmds(code_changes: List[dict]) -> Tuple[str, str]: we still use absolute paths to keep the prompt cwd-agnostic — it must work no matter where the user runs the slash command from. """ - smoke = f"python3 {WORKSPACE_ROOT}/.rpgkit/scripts/smoke_test.py --json" + smoke = f"{cmd_for("smoke_test.py")} --json" patterns = _derive_test_files(code_changes) if patterns: diff --git a/RPG-Kit/scripts/rpg_edit/review.py b/RPG-Kit/scripts/rpg_edit/review.py index 7ae478a..7e7552a 100644 --- a/RPG-Kit/scripts/rpg_edit/review.py +++ b/RPG-Kit/scripts/rpg_edit/review.py @@ -6,7 +6,7 @@ data (callers, affected_files), NOT a full global review. Usage: - python3 .rpgkit/scripts/rpg_edit/review.py \ + rpgkit script rpg_edit/review.py \ --plan .rpgkit/data/rpg_edit_plan.json \ --impact .rpgkit/data/rpg_edit_impact.json \ --json @@ -34,7 +34,7 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import WORKSPACE_ROOT, REPO_DIR # noqa: E402 +from common.paths import WORKSPACE_ROOT, REPO_DIR, cmd_for # noqa: E402 logger = logging.getLogger(__name__) @@ -344,7 +344,7 @@ def build_impact_review_prompt( tools_dir = WORKSPACE_ROOT / ".rpgkit" / "scripts" / "tools" browser_tool = str(tools_dir / "browser.py") gui_tool = str(tools_dir / "gui.py") - smoke_test_cmd = f"python3 {WORKSPACE_ROOT}/.rpgkit/scripts/smoke_test.py --json" + smoke_test_cmd = f"{cmd_for("smoke_test.py")} --json" # Start instructions depend on project type start_instructions = ( diff --git a/RPG-Kit/scripts/rpg_edit/validate.py b/RPG-Kit/scripts/rpg_edit/validate.py index ca1ccf6..6ca3b5f 100644 --- a/RPG-Kit/scripts/rpg_edit/validate.py +++ b/RPG-Kit/scripts/rpg_edit/validate.py @@ -48,7 +48,7 @@ def main(): if not has_dep_graph and not args.dep_graph.exists(): result = {"type": "error", "error_code": "dep_graph_not_found", "message": f"dep_graph.json not found: {args.dep_graph}. " - "Run `python3 .rpgkit/scripts/update_graphs.py sync` " + "Run `rpgkit script update_graphs.py sync` " "to build it from the current code."} print(json.dumps(result) if args.json else f"Error: {result['message']}") return 1 diff --git a/RPG-Kit/scripts/rpg_encoder/run_encode.py b/RPG-Kit/scripts/rpg_encoder/run_encode.py index 3ab16b3..36409b6 100644 --- a/RPG-Kit/scripts/rpg_encoder/run_encode.py +++ b/RPG-Kit/scripts/rpg_encoder/run_encode.py @@ -7,8 +7,8 @@ Prints a single JSON result to stdout with status and statistics. Usage: - python3 .rpgkit/scripts/rpg_encoder/run_encode.py --json - python3 .rpgkit/scripts/rpg_encoder/run_encode.py --repo-dir ./my-project + rpgkit script rpg_encoder/run_encode.py --json + rpgkit script rpg_encoder/run_encode.py --repo-dir ./my-project """ import json diff --git a/RPG-Kit/scripts/rpg_encoder/run_update_rpg.py b/RPG-Kit/scripts/rpg_encoder/run_update_rpg.py index 02f3712..67a6071 100644 --- a/RPG-Kit/scripts/rpg_encoder/run_update_rpg.py +++ b/RPG-Kit/scripts/rpg_encoder/run_update_rpg.py @@ -7,7 +7,7 @@ Prints a single JSON result to stdout with status and diff statistics. Usage: - python3 .rpgkit/scripts/rpg_encoder/run_update_rpg.py --json \\ + rpgkit script rpg_encoder/run_update_rpg.py --json \\ --rpg-file .rpgkit/data/rpg.json --last-repo-dir ./old-version """ diff --git a/RPG-Kit/scripts/run_batch.py b/RPG-Kit/scripts/run_batch.py index 0a26304..40f40fa 100644 --- a/RPG-Kit/scripts/run_batch.py +++ b/RPG-Kit/scripts/run_batch.py @@ -61,6 +61,7 @@ LOGS_DIR as _LOGS_DIR, WORKSPACE_ROOT, get_scripts_dir, + cmd_for, REPO_DIR, ) from code_gen.context_collector import build_dependency_context @@ -679,7 +680,7 @@ def run_batch( return _error( f"Tests pass but branch merge failed: {merge_error}. " f"Branch '{branch_name}' preserved. " - f"Retry: python3 {scripts}/run_batch.py --retry {batch_id} --json", + f"Retry: {cmd_for("run_batch.py")} --retry {batch_id} --json", scripts, ) state_complete_batch(batch_id, True, state_path, rpg_backup_path=rpg_backup) @@ -816,7 +817,7 @@ def run_batch( return _error( f"Tests passed but branch merge failed: {merge_error}. " f"Branch '{branch_name}' preserved. " - f"Retry: python3 {scripts}/run_batch.py --retry {batch_id} --json", + f"Retry: {cmd_for("run_batch.py")} --retry {batch_id} --json", scripts, ) diff --git a/RPG-Kit/scripts/smoke_test.py b/RPG-Kit/scripts/smoke_test.py index cd0a80a..9dfb7ea 100644 --- a/RPG-Kit/scripts/smoke_test.py +++ b/RPG-Kit/scripts/smoke_test.py @@ -34,7 +34,7 @@ # --------------------------------------------------------------------------- sys.path.insert(0, str(Path(__file__).parent)) -from common.paths import DEV_VENV_DIR, REPO_DIR, get_scripts_dir +from common.paths import DEV_VENV_DIR, REPO_DIR, get_scripts_dir, cmd_for logger = logging.getLogger(__name__) @@ -405,7 +405,7 @@ def main() -> int: scripts = get_scripts_dir() if not result.success: print("\n Fix the issues above, then re-run:") - print(f" python3 {scripts}/smoke_test.py --json") + print(f" {cmd_for("smoke_test.py")} --json") return 0 if result.success else 1 diff --git a/RPG-Kit/scripts/update_graphs.py b/RPG-Kit/scripts/update_graphs.py index 0deacb3..82edd80 100644 --- a/RPG-Kit/scripts/update_graphs.py +++ b/RPG-Kit/scripts/update_graphs.py @@ -13,11 +13,11 @@ full AST scan + mappings + edges (legacy, use 'sync' instead) Usage: - python3 .rpgkit/scripts/update_graphs.py dep --json - python3 .rpgkit/scripts/update_graphs.py enrich --json - python3 .rpgkit/scripts/update_graphs.py enrich --file models/user.py --dry-run --json - python3 .rpgkit/scripts/update_graphs.py sync --json - python3 .rpgkit/scripts/update_graphs.py update-rpg --json + rpgkit script update_graphs.py dep --json + rpgkit script update_graphs.py enrich --json + rpgkit script update_graphs.py enrich --file models/user.py --dry-run --json + rpgkit script update_graphs.py sync --json + rpgkit script update_graphs.py update-rpg --json """ import argparse From a74c7efefa7d10f54ecb9b56e3925c37484a8bd9 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Tue, 19 May 2026 17:25:33 +0800 Subject: [PATCH 08/31] feat(cli): drop workspace scripts copy + rewrite templates (Batch B-3/4/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 02 Batch B steps 3-5: complete the migration to globally-installed scripts. - Rewrite 13 slash-command templates: 'python3 .rpgkit/scripts/X.py' → 'rpgkit script X.py' (64 substitutions via sed). - _install_from_bundle: stop copying scripts to .rpgkit/scripts/; only materialise slash-command templates and the .source marker. - _download_and_extract_release_zip (legacy --legacy-download): strip the extracted .rpgkit/scripts/ after extraction; legacy channel now delivers commands only (D7). - ensure_executable_scripts: collapse to a deprecated no-op stub. - .gitignore: drop the obsolete .rpgkit/scripts/**/__pycache__/ rule (covered by the blanket .rpgkit/ ignore anyway). After init the workspace contains only data/, logs/, config.toml, .source — no scripts/ dir. All pipeline scripts run from the wheel via 'rpgkit script '. Test baseline unchanged (11 failed / 883 passed, all pre-existing failures). --- RPG-Kit/.gitignore | 1 - RPG-Kit/src/rpgkit_cli/__init__.py | 113 ++++++------------ RPG-Kit/templates/commands/build_data_flow.md | 8 +- RPG-Kit/templates/commands/build_skeleton.md | 8 +- RPG-Kit/templates/commands/code_gen.md | 30 ++--- .../templates/commands/design_base_classes.md | 6 +- .../templates/commands/design_interfaces.md | 6 +- RPG-Kit/templates/commands/encode.md | 4 +- RPG-Kit/templates/commands/feature_build.md | 10 +- RPG-Kit/templates/commands/feature_edit.md | 4 +- .../templates/commands/feature_refactor.md | 4 +- RPG-Kit/templates/commands/feature_spec.md | 2 +- RPG-Kit/templates/commands/plan_tasks.md | 6 +- RPG-Kit/templates/commands/rpg_edit.md | 26 ++-- RPG-Kit/templates/commands/update_rpg.md | 4 +- 15 files changed, 94 insertions(+), 138 deletions(-) diff --git a/RPG-Kit/.gitignore b/RPG-Kit/.gitignore index fafac9a..3bcc672 100644 --- a/RPG-Kit/.gitignore +++ b/RPG-Kit/.gitignore @@ -7,7 +7,6 @@ __pycache__/ # --- RPG-Kit generated data & temp --- .rpgkit/data/ .rpgkit/tmp/ -.rpgkit/scripts/**/__pycache__/ # --- Logs --- *.log diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 2baed2d..9b013f0 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -3017,12 +3017,18 @@ def _install_from_bundle( verbose: bool = True, tracker: StepTracker | None = None, ) -> Path: - """Copy packaged scripts + per-AI command templates into the workspace. - - Mirrors the post-extract layout produced by the legacy zip path so - that downstream steps (``ensure_executable_scripts``, - ``_setup_gitignore``, ``_generate_mcp_config``, ``_install_hooks``) - work unchanged. + """Materialise per-AI command templates into the workspace. + + The pipeline scripts themselves live inside the installed wheel at + ``rpgkit_cli/core_pack/scripts/`` and are invoked via ``rpgkit + script `` (and ``rpgkit-mcp`` for the MCP server) — they are + NOT copied to ``/.rpgkit/scripts/`` anymore. See plan 02 + for the motivation: a single source of truth per CLI install, no + risk of workspace/wheel drift, and no per-workspace scripts dir + to keep in sync. + + Only slash-command templates land in the workspace, plus the + provisioning marker that records which channel was used. """ from . import _assets @@ -3042,39 +3048,22 @@ def _install_from_bundle( try: rpgkit_root = project_path / ".rpgkit" - rpgkit_scripts = rpgkit_root / "scripts" - rpgkit_scripts.parent.mkdir(parents=True, exist_ok=True) - - # 1. Copy scripts. Use shutil.copytree with dirs_exist_ok so - # that re-running on an existing workspace overwrites stale - # pipeline scripts without complaint. - src_scripts = _assets.scripts_dir() - shutil.copytree( - src_scripts, - rpgkit_scripts, - dirs_exist_ok=True, - ignore=shutil.ignore_patterns( - "__pycache__", "*.pyc", ".pytest_cache", ".mypy_cache" - ), - ) + rpgkit_root.mkdir(parents=True, exist_ok=True) - # 2. Materialise slash-command templates into the AI-specific + # 1. Materialise slash-command templates into the AI-specific # directory. _materialise_commands_for_agent owns the - # per-agent file-name / folder rules and matches what the - # legacy zip path produces (down to the rpgkit..md - # prefix), so downstream consumers see the same layout - # regardless of provisioning source. + # per-agent file-name / folder rules. _materialise_commands_for_agent( ai_assistant, _assets.commands_dir(), project_path ) - # 3. Record the provisioning source so subsequent ``rpgkit update`` + # 2. Record the provisioning source so subsequent ``rpgkit update`` # invocations default to the same channel. _write_source_marker(project_path, _SOURCE_BUNDLE) if tracker: tracker.skip("zip-list", "bundle (no archive)") - tracker.skip("extracted-summary", "bundle copied") + tracker.skip("extracted-summary", "templates only") tracker.complete("extract") tracker.skip("cleanup", "bundle mode") except Exception as e: @@ -3350,62 +3339,30 @@ def _download_and_extract_release_zip( # writes ``bundle``. Plan §2.5 (decision 12). _write_source_marker(project_path, _SOURCE_LEGACY) + # Discard the scripts copy extracted from the zip — they're not + # used at runtime anymore (the workspace invokes ``rpgkit script + # `` which resolves to the packaged scripts dir). Keeping + # them would just be dead weight that drifts vs the installed CLI. + # Plan 02 D7: legacy zip contributes commands only. + legacy_scripts_dir = project_path / ".rpgkit" / "scripts" + if legacy_scripts_dir.is_dir(): + shutil.rmtree(legacy_scripts_dir, ignore_errors=True) + return project_path def ensure_executable_scripts( project_path: Path, tracker: StepTracker | None = None ) -> None: - """Ensure POSIX .sh scripts under .rpgkit/scripts (recursively) have execute bits (no-op on Windows).""" - if os.name == "nt": - return # Windows: skip silently - scripts_root = project_path / ".rpgkit" / "scripts" - if not scripts_root.is_dir(): - return - failures: list[str] = [] - updated = 0 - for script in scripts_root.rglob("*.sh"): - try: - if script.is_symlink() or not script.is_file(): - continue - try: - with script.open("rb") as f: - if f.read(2) != b"#!": - continue - except Exception: - continue - st = script.stat() - mode = st.st_mode - if mode & 0o111: - continue - new_mode = mode - if mode & 0o400: - new_mode |= 0o100 - if mode & 0o040: - new_mode |= 0o010 - if mode & 0o004: - new_mode |= 0o001 - if not (new_mode & 0o100): - new_mode |= 0o100 - os.chmod(script, new_mode) - updated += 1 - except Exception as e: - failures.append(f"{script.relative_to(scripts_root)}: {e}") - if tracker: - detail = f"{updated} updated" + ( - f", {len(failures)} failed" if failures else "" - ) - tracker.add("chmod", "Set script permissions recursively") - (tracker.error if failures else tracker.complete)("chmod", detail) - else: - if updated: - console.print( - f"[cyan]Updated execute permissions on {updated} script(s) recursively[/cyan]" - ) - if failures: - console.print("[yellow]Some scripts could not be updated:[/yellow]") - for f in failures: - console.print(f" - {f}") + """Deprecated no-op. + + Previously ensured POSIX execute bits on ``.rpgkit/scripts/**/*.sh``. + After plan 02, scripts live inside the installed wheel where the + exec bits are set at install time by the packaging tool, and the + workspace no longer hosts a scripts copy. Kept as a stub to keep + existing call sites simple; safe to remove in a future cleanup PR. + """ + return def ensure_rpgkit_runtime_dirs( diff --git a/RPG-Kit/templates/commands/build_data_flow.md b/RPG-Kit/templates/commands/build_data_flow.md index 009423e..909b162 100644 --- a/RPG-Kit/templates/commands/build_data_flow.md +++ b/RPG-Kit/templates/commands/build_data_flow.md @@ -22,7 +22,7 @@ Unless it is explicitly empty, you may assume it is always available as `$ARGUME ### Step 1: Pre-check -Run the script `python3 .rpgkit/scripts/check_data_flow.py` to verify the current state. +Run the script `rpgkit script check_data_flow.py` to verify the current state. 1. Inspect the `state` field in the output: @@ -81,7 +81,7 @@ Run the script `python3 .rpgkit/scripts/check_data_flow.py` to verify the curren 2. Execute the following command with the selected iteration count: ```bash - python3 .rpgkit/scripts/build_data_flow.py --max-iterations > .rpgkit/logs/build_data_flow.log 2>&1 + rpgkit script build_data_flow.py --max-iterations > .rpgkit/logs/build_data_flow.log 2>&1 ``` Then print the output by: @@ -108,7 +108,7 @@ Run the script `python3 .rpgkit/scripts/check_data_flow.py` to verify the curren Run the validation script: ```bash -python3 .rpgkit/scripts/check_data_flow.py --verbose +rpgkit script check_data_flow.py --verbose ``` Display the validation results to the user: @@ -128,7 +128,7 @@ Display the validation results to the user: Run the visualization script: ```bash -python3 .rpgkit/scripts/generate_viz.py +rpgkit script generate_viz.py ``` Report: diff --git a/RPG-Kit/templates/commands/build_skeleton.md b/RPG-Kit/templates/commands/build_skeleton.md index 50f755c..6d1e88d 100644 --- a/RPG-Kit/templates/commands/build_skeleton.md +++ b/RPG-Kit/templates/commands/build_skeleton.md @@ -20,7 +20,7 @@ Unless it is explicitly empty, you may assume it is always available as `$ARGUME ### Step 1: Pre-check -Run the script `python3 .rpgkit/scripts/check_skeleton.py` to verify the current state. +Run the script `rpgkit script check_skeleton.py` to verify the current state. 1. Inspect the `type` field in the output: @@ -69,7 +69,7 @@ Run the script `python3 .rpgkit/scripts/check_skeleton.py` to verify the current 2. Execute the following command with the selected iteration count: ```bash - python3 .rpgkit/scripts/build_skeleton.py --max-iterations > .rpgkit/logs/build_skeleton.log 2>&1 + rpgkit script build_skeleton.py --max-iterations > .rpgkit/logs/build_skeleton.log 2>&1 ``` Then print the output by: @@ -97,7 +97,7 @@ Run the script `python3 .rpgkit/scripts/check_skeleton.py` to verify the current Run the validation script: ```bash -python3 .rpgkit/scripts/check_skeleton.py --verbose +rpgkit script check_skeleton.py --verbose ``` Display the validation results to the user: @@ -115,7 +115,7 @@ Display the validation results to the user: Run the summary script to generate a formatted report and save to file: ```bash -python3 .rpgkit/scripts/summary_skeleton.py +rpgkit script summary_skeleton.py ``` This saves the summary (including directory structure, component paths, and statistics) to `.rpgkit/data/skeleton_summary.txt`. diff --git a/RPG-Kit/templates/commands/code_gen.md b/RPG-Kit/templates/commands/code_gen.md index 334f85a..adfe9a9 100644 --- a/RPG-Kit/templates/commands/code_gen.md +++ b/RPG-Kit/templates/commands/code_gen.md @@ -18,7 +18,7 @@ runs pytest, and fixes issues — up to 5 iterations per attempt, 2 attempts per Run the check script to determine current state: ```bash -python3 .rpgkit/scripts/check_code_gen.py --json +rpgkit script check_code_gen.py --json ``` **If type is "error"**: @@ -31,7 +31,7 @@ python3 .rpgkit/scripts/check_code_gen.py --json **If type is "in_progress"**: -* Run `python3 .rpgkit/scripts/run_batch.py --resume --json` to resume +* Run `rpgkit script run_batch.py --resume --json` to resume **If type is "complete"**: @@ -42,7 +42,7 @@ python3 .rpgkit/scripts/check_code_gen.py --json **This step is only needed once**, before the first batch. ```bash -python3 .rpgkit/scripts/init_codebase.py --json +rpgkit script init_codebase.py --json ``` This creates README.md, .gitignore, base classes, and an initial commit. @@ -96,19 +96,19 @@ Remember both choices for the session. **Single-batch mode:** ```bash -python3 .rpgkit/scripts/run_batch.py --next --json +rpgkit script run_batch.py --next --json ``` **File-merge mode (no unit limit):** ```bash -python3 .rpgkit/scripts/run_batch.py --next --merge-file --json +rpgkit script run_batch.py --next --merge-file --json ``` **File-merge mode (with unit limit):** ```bash -python3 .rpgkit/scripts/run_batch.py --next --merge-file --max-units --json +rpgkit script run_batch.py --next --merge-file --max-units --json ``` **Read the JSON output:** @@ -129,7 +129,7 @@ Continue until `type` is `"complete"` or no tasks remain. When all batches are processed: ```bash -python3 .rpgkit/scripts/run_batch.py --final-test --json +rpgkit script run_batch.py --final-test --json ``` This runs pytest (full suite) and smoke test (import check, entry point, stub detection). @@ -140,7 +140,7 @@ If smoke test reports errors, a repair agent is dispatched automatically. After final test passes, run the global review: ```bash -python3 .rpgkit/scripts/run_batch.py --global-review --json +rpgkit script run_batch.py --global-review --json ``` This dispatches a sub-agent that: @@ -167,7 +167,7 @@ This step can be re-run independently without re-running `--final-test`. Next steps: • Review failed batches (branches preserved for inspection) - • Run: python3 .rpgkit/scripts/run_batch.py --retry --json + • Run: rpgkit script run_batch.py --retry --json ``` --- @@ -176,19 +176,19 @@ This step can be re-run independently without re-running `--final-test`. ```bash # Resume an interrupted batch -python3 .rpgkit/scripts/run_batch.py --resume --json +rpgkit script run_batch.py --resume --json # Retry a specific failed batch -python3 .rpgkit/scripts/run_batch.py --retry --json +rpgkit script run_batch.py --retry --json # Run a specific batch by ID -python3 .rpgkit/scripts/run_batch.py --batch-id --json +rpgkit script run_batch.py --batch-id --json # Repo validation (pytest + smoke) -python3 .rpgkit/scripts/run_batch.py --final-test --json +rpgkit script run_batch.py --final-test --json # Full feature review + visual QA -python3 .rpgkit/scripts/run_batch.py --global-review --json +rpgkit script run_batch.py --global-review --json ``` ## Recovery @@ -196,7 +196,7 @@ python3 .rpgkit/scripts/run_batch.py --global-review --json To resume from any state: ```bash -python3 .rpgkit/scripts/check_code_gen.py --json +rpgkit script check_code_gen.py --json ``` Follow the `next_action` field — it always tells you the exact command to run. diff --git a/RPG-Kit/templates/commands/design_base_classes.md b/RPG-Kit/templates/commands/design_base_classes.md index 6539702..f145056 100644 --- a/RPG-Kit/templates/commands/design_base_classes.md +++ b/RPG-Kit/templates/commands/design_base_classes.md @@ -20,7 +20,7 @@ Unless it is explicitly empty, you may assume it is always available as `$ARGUME ### Step 1: Pre-check -Run the script `python3 .rpgkit/scripts/check_base_classes.py` to verify the current state. +Run the script `rpgkit script check_base_classes.py` to verify the current state. 1. Inspect the `state` field in the output: @@ -66,7 +66,7 @@ Run the script `python3 .rpgkit/scripts/check_base_classes.py` to verify the cur 2. Execute the following command with the selected iteration count: ```bash - python3 .rpgkit/scripts/design_base_classes.py --max-iterations > .rpgkit/logs/design_base_classes.log 2>&1 + rpgkit script design_base_classes.py --max-iterations > .rpgkit/logs/design_base_classes.log 2>&1 ``` Then print the output by: @@ -95,7 +95,7 @@ Run the script `python3 .rpgkit/scripts/check_base_classes.py` to verify the cur Run the validation script: ```bash -python3 .rpgkit/scripts/check_base_classes.py --verbose +rpgkit script check_base_classes.py --verbose ``` Display the validation results to the user: diff --git a/RPG-Kit/templates/commands/design_interfaces.md b/RPG-Kit/templates/commands/design_interfaces.md index c11ebf1..8526869 100644 --- a/RPG-Kit/templates/commands/design_interfaces.md +++ b/RPG-Kit/templates/commands/design_interfaces.md @@ -16,7 +16,7 @@ Design function and class interfaces for your repository files based on the skel Run the check script to determine current state: ```bash -python3 .rpgkit/scripts/check_interfaces.py --json +rpgkit script check_interfaces.py --json ``` **If type is "error"**: @@ -62,7 +62,7 @@ python3 .rpgkit/scripts/check_interfaces.py --json Run the interface designer: ```bash -python3 .rpgkit/scripts/design_interfaces.py > .rpgkit/logs/design_interfaces.log 2>&1 +rpgkit script design_interfaces.py > .rpgkit/logs/design_interfaces.log 2>&1 ``` Then print the output by: @@ -89,7 +89,7 @@ defined by the data flow DAG. This ensures dependencies are resolved correctly. After generation, run the check script again: ```bash -python3 .rpgkit/scripts/check_interfaces.py --json +rpgkit script check_interfaces.py --json ``` Verify: diff --git a/RPG-Kit/templates/commands/encode.md b/RPG-Kit/templates/commands/encode.md index 782da95..592fb65 100644 --- a/RPG-Kit/templates/commands/encode.md +++ b/RPG-Kit/templates/commands/encode.md @@ -23,7 +23,7 @@ code entities) and edges (dependencies, containment). Run the check script to determine the current encode state: ```bash -python3 .rpgkit/scripts/rpg_encoder/check_encode.py --json +rpgkit script rpg_encoder/check_encode.py --json ``` Inspect the `type` field in the output: @@ -62,7 +62,7 @@ Inspect the `type` field in the output: Run the full encode script: ```bash -python3 .rpgkit/scripts/rpg_encoder/run_encode.py --json > .rpgkit/logs/encode.log 2>&1 +rpgkit script rpg_encoder/run_encode.py --json > .rpgkit/logs/encode.log 2>&1 ``` This may take several minutes depending on repository size and LLM response times. diff --git a/RPG-Kit/templates/commands/feature_build.md b/RPG-Kit/templates/commands/feature_build.md index 55328b8..4b29338 100644 --- a/RPG-Kit/templates/commands/feature_build.md +++ b/RPG-Kit/templates/commands/feature_build.md @@ -19,7 +19,7 @@ This workflow has four steps: Execute the following command to check the current state of input/output files: ```bash -python3 .rpgkit/scripts/feature_build_validation.py +rpgkit script feature_build_validation.py ``` **After execution, parse the JSON output and display a user-friendly summary.** @@ -61,7 +61,7 @@ The script automatically detects whether the output file (`feature_build.json`) 1. **Execute the command:** ```bash - python3 .rpgkit/scripts/feature_build.py \ + rpgkit script feature_build.py \ --mode step1 > .rpgkit/logs/feature_build.log 2>&1 ``` @@ -116,7 +116,7 @@ After the spec-driven build is complete, ask the user whether they want to expan a. **Get expansion direction suggestions:** ```bash - python3 .rpgkit/scripts/feature_build.py \ + rpgkit script feature_build.py \ --mode suggest-directions > .rpgkit/logs/feature_build.log 2>&1 ``` @@ -150,7 +150,7 @@ After the spec-driven build is complete, ask the user whether they want to expan Then pass the normalized indices to the script: ```bash - python3 .rpgkit/scripts/feature_build.py \ + rpgkit script feature_build.py \ --mode step2 \ --direction "" > .rpgkit/logs/feature_build.log 2>&1 ``` @@ -158,7 +158,7 @@ After the spec-driven build is complete, ask the user whether they want to expan For example, if the user enters `1,3,5`: ```bash - python3 .rpgkit/scripts/feature_build.py \ + rpgkit script feature_build.py \ --mode step2 \ --direction "1,3,5" > .rpgkit/logs/feature_build.log 2>&1 ``` diff --git a/RPG-Kit/templates/commands/feature_edit.md b/RPG-Kit/templates/commands/feature_edit.md index ecab1d7..108fa65 100644 --- a/RPG-Kit/templates/commands/feature_edit.md +++ b/RPG-Kit/templates/commands/feature_edit.md @@ -37,7 +37,7 @@ The text typed by the user after `/rpgkit.feature_edit` **is the edit instructio Execute from repository root: ```bash -python3 .rpgkit/scripts/feature_edit_validation.py --edit_instruction "$ARGUMENTS" +rpgkit script feature_edit_validation.py --edit_instruction "$ARGUMENTS" ``` **Important:** If `$ARGUMENTS` contains a double quote (`"`), it MUST be escaped before being passed to the script. @@ -96,7 +96,7 @@ Please confirm to proceed: Execute the following command: ```bash -python3 .rpgkit/scripts/feature_edit.py > .rpgkit/logs/feature_edit.log 2>&1 +rpgkit script feature_edit.py > .rpgkit/logs/feature_edit.log 2>&1 ``` Then print the output by: diff --git a/RPG-Kit/templates/commands/feature_refactor.md b/RPG-Kit/templates/commands/feature_refactor.md index dbb4322..19df6b9 100644 --- a/RPG-Kit/templates/commands/feature_refactor.md +++ b/RPG-Kit/templates/commands/feature_refactor.md @@ -10,7 +10,7 @@ name: rpgkit.feature_refactor 1. Run the validation script to verify input and check output file status: ```bash - python3 .rpgkit/scripts/feature_refactor_validation.py + rpgkit script feature_refactor_validation.py ``` The script outputs a JSON object. Determine the next action based on the `status` and `action` fields: @@ -47,7 +47,7 @@ name: rpgkit.feature_refactor 2. Execute the following command with the selected max iteration count (default: 10 or user-defined): ```bash - python3 .rpgkit/scripts/feature_refactor.py --max-iterations > .rpgkit/logs/feature_refactor.log 2>&1 + rpgkit script feature_refactor.py --max-iterations > .rpgkit/logs/feature_refactor.log 2>&1 ``` Then print the output by: diff --git a/RPG-Kit/templates/commands/feature_spec.md b/RPG-Kit/templates/commands/feature_spec.md index d13da79..7003848 100644 --- a/RPG-Kit/templates/commands/feature_spec.md +++ b/RPG-Kit/templates/commands/feature_spec.md @@ -607,7 +607,7 @@ Convert generated Markdown feature specification files to JSON format. Execute the following command: ```bash -python3 .rpgkit/scripts/feature_spec_to_json.py +rpgkit script feature_spec_to_json.py ``` #### 5.2: Verify Output diff --git a/RPG-Kit/templates/commands/plan_tasks.md b/RPG-Kit/templates/commands/plan_tasks.md index 3451392..467f7bc 100644 --- a/RPG-Kit/templates/commands/plan_tasks.md +++ b/RPG-Kit/templates/commands/plan_tasks.md @@ -14,7 +14,7 @@ Create implementation tasks from the interface definitions. Run the check script to determine current state: ```bash -python3 .rpgkit/scripts/check_tasks.py --json +rpgkit script check_tasks.py --json ``` **If type is "error"**: @@ -60,7 +60,7 @@ python3 .rpgkit/scripts/check_tasks.py --json Run the task planner: ```bash -python3 .rpgkit/scripts/plan_tasks.py > .rpgkit/logs/plan_tasks.log 2>&1 +rpgkit script plan_tasks.py > .rpgkit/logs/plan_tasks.log 2>&1 ``` Then print the output by: @@ -84,7 +84,7 @@ This will: After generation, run the check script again: ```bash -python3 .rpgkit/scripts/check_tasks.py --json +rpgkit script check_tasks.py --json ``` Verify: diff --git a/RPG-Kit/templates/commands/rpg_edit.md b/RPG-Kit/templates/commands/rpg_edit.md index ac50aed..f1388af 100644 --- a/RPG-Kit/templates/commands/rpg_edit.md +++ b/RPG-Kit/templates/commands/rpg_edit.md @@ -41,7 +41,7 @@ The text after `/rpgkit.rpg_edit` is the edit instruction, available as `$ARGUME ### Step 1: Pre-check ```bash -python3 .rpgkit/scripts/rpg_edit/validate.py --json +rpgkit script rpg_edit/validate.py --json ``` Inspect the `type` field: @@ -52,7 +52,7 @@ Inspect the `type` field: ### Step 2: Locate Target Nodes ```bash -python3 .rpgkit/scripts/rpg_edit/locate.py --query "$ARGUMENTS" --json +rpgkit script rpg_edit/locate.py --query "$ARGUMENTS" --json ``` > **Note:** If `$ARGUMENTS` contains double quotes, escape them before passing. @@ -78,7 +78,7 @@ plus a `tree_summary` showing the full RPG structure for orientation. For each selected node, run impact analysis and save the output: ```bash -python3 .rpgkit/scripts/rpg_edit/impact.py --node-id [--node-id ...] --json | tee .rpgkit/data/rpg_edit_impact.json +rpgkit script rpg_edit/impact.py --node-id [--node-id ...] --json | tee .rpgkit/data/rpg_edit_impact.json ``` Read the output to inform the EditPlan. Do NOT present it separately — incorporate the results directly into Step 4. @@ -104,7 +104,7 @@ If no keyword matches, skip directly to Step 4. **Step 3.5a — Probe tool availability (≤ 5s):** ```bash -python3 .rpgkit/scripts/tools/browser.py check >/dev/null 2>&1 \ +rpgkit script tools/browser.py check >/dev/null 2>&1 \ && BROWSER_OK=1 || BROWSER_OK=0 ``` @@ -126,7 +126,7 @@ Step 4. **Step 3.5c — Run inspect:** ```bash -python3 .rpgkit/scripts/tools/browser.py inspect +rpgkit script tools/browser.py inspect ``` The command prints paths to the saved HTML and screenshot. Read the @@ -161,7 +161,7 @@ assumptions from node names. Poor plans come from skipping this step. was skipped but the app is running, take a screenshot now: ```bash - python3 .rpgkit/scripts/tools/browser.py inspect http://localhost:/ + rpgkit script tools/browser.py inspect http://localhost:/ ``` 4. **Collect all files that need changes** — not just the ones from @@ -263,7 +263,7 @@ do **not** silently `git stash`, as that would hide their work. **Step 5b — Update RPG feature graph:** ```bash -python3 .rpgkit/scripts/rpg_edit/apply.py --plan .rpgkit/data/rpg_edit_plan.json --phase rpg-only --json +rpgkit script rpg_edit/apply.py --plan .rpgkit/data/rpg_edit_plan.json --phase rpg-only --json ``` This applies `feature_changes` to the RPG and saves it. The RPG now reflects the target state. @@ -277,7 +277,7 @@ mode, and the driver script creates a single commit on the current branch (even when multiple SubAgent iterations are needed). ```bash -python3 .rpgkit/scripts/rpg_edit/code.py \ +rpgkit script rpg_edit/code.py \ --plan .rpgkit/data/rpg_edit_plan.json \ --json | tee .rpgkit/data/rpg_edit_code_result.json ``` @@ -293,7 +293,7 @@ If success, refresh the dep_graph and amend the existing commit so that code + dep_graph land together: ```bash -python3 .rpgkit/scripts/rpg_edit/apply.py \ +rpgkit script rpg_edit/apply.py \ --plan .rpgkit/data/rpg_edit_plan.json \ --phase dep-refresh --backup-ts --json @@ -305,13 +305,13 @@ git add -A && git commit --amend --no-edit 1. **Smoke test** — verify imports and entry point: ```bash -python3 .rpgkit/scripts/smoke_test.py --json +rpgkit script smoke_test.py --json ``` 1. **Impact review** — run targeted tests and verify affected functionality: ```bash -python3 .rpgkit/scripts/rpg_edit/review.py \ +rpgkit script rpg_edit/review.py \ --plan .rpgkit/data/rpg_edit_plan.json \ --impact .rpgkit/data/rpg_edit_impact.json \ --json @@ -354,7 +354,7 @@ visible in `git log --graph`. > Merged `rpg-edit/` into `main` (commit ``). > To revert later: > - Code: `git revert -m 1 ` - > - Graphs: `python3 .rpgkit/scripts/rpg_edit/apply.py --rollback --json` + > - Graphs: `rpgkit script rpg_edit/apply.py --rollback --json` If the review output contained `suggestions`, append: @@ -378,7 +378,7 @@ visible in `git log --graph`. > `main` is clean. Choose one of: > - Inspect: `git diff main rpg-edit/` > - Discard code + graphs together: - > `python3 .rpgkit/scripts/rpg_edit/apply.py --rollback --rollback-branch rpg-edit/ --json` + > `rpgkit script rpg_edit/apply.py --rollback --rollback-branch rpg-edit/ --json` > - Discard code only: `git branch -D rpg-edit/` > - Continue editing on the branch and re-run from Step 5d. diff --git a/RPG-Kit/templates/commands/update_rpg.md b/RPG-Kit/templates/commands/update_rpg.md index 56f865e..c978c19 100644 --- a/RPG-Kit/templates/commands/update_rpg.md +++ b/RPG-Kit/templates/commands/update_rpg.md @@ -35,7 +35,7 @@ uses) and runs the LLM-driven feature graph diff + dep_graph rebuild. Run the check script: ```bash -python3 .rpgkit/scripts/rpg_encoder/check_encode.py --json +rpgkit script rpg_encoder/check_encode.py --json ``` Inspect the `type` field in the JSON output: @@ -66,7 +66,7 @@ manually**. ```bash mkdir -p .rpgkit/logs -python3 .rpgkit/scripts/update_graphs.py update-rpg --json \ +rpgkit script update_graphs.py update-rpg --json \ > .rpgkit/logs/update_rpg.log 2>&1 ``` From 3c1d7c139734634413bb8fa7115cd6b412e3c115 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Tue, 19 May 2026 17:34:08 +0800 Subject: [PATCH 09/31] docs: reflect rpgkit script + global scripts dir (Batch B-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 02 Batch B step 8: rewrite documentation to match the new contract. - docs/cli-reference.md: add full 'rpgkit script' section (synopsis, options --list/--where, examples, rpgkit-mcp companion mention). - docs/commands.md: rewrite 13 inline references from '.rpgkit/scripts/X.py' → 'rpgkit script X.py'. - docs/project-structure.md: drop the obsolete .rpgkit/scripts/ subtree from the workspace layout diagram; add a callout explaining the packaged-scripts model. Plan 02 self-repo agent prompt regeneration (Batch B-6) is gitignored in this repo so changes there are not part of this commit; the next 'rpgkit init/update' on the RPG-Kit dev workspace picks up the new template content automatically. --- RPG-Kit/docs/cli-reference.md | 42 ++++++++++++++++++++++++++ RPG-Kit/docs/commands.md | 24 +++++++-------- RPG-Kit/docs/project-structure.md | 50 ++++--------------------------- 3 files changed, 60 insertions(+), 56 deletions(-) diff --git a/RPG-Kit/docs/cli-reference.md b/RPG-Kit/docs/cli-reference.md index e9a6332..b69e426 100644 --- a/RPG-Kit/docs/cli-reference.md +++ b/RPG-Kit/docs/cli-reference.md @@ -104,6 +104,48 @@ Display version and system information. rpgkit version ``` +## `rpgkit script` + +Execute one of the bundled RPG-Kit pipeline scripts. After install +(`uv tool install rpgkit-cli`) the scripts live inside the wheel under +`rpgkit_cli/core_pack/scripts/` and are no longer copied into each +workspace; this command is the supported way to invoke them. + +```bash +rpgkit script [args...] +``` + +Arguments after `` are forwarded verbatim to the target +script. Standard input/output/error and exit code are inherited. + +### Options + +- `--list` — print every available script (relative path) and exit. +- `--where ` — print the absolute filesystem path of one script + and exit; pipeable into `$(...)` for ad-hoc inspection. + +The `.py` suffix on `` is optional. Path traversal (`..`) +and absolute paths are rejected for safety. + +### Examples + +```bash +rpgkit script smoke_test.py --json +rpgkit script rpg_edit/validate.py +rpgkit script --list +rpgkit script --where mcp_server.py +``` + +The slash-command templates installed by `rpgkit init` (in +`.claude/commands/` or `.github/agents/`) all use `rpgkit script …` +under the hood, so AI agents invoke the pipeline through the same +contract. + +A companion console script, `rpgkit-mcp`, is the MCP server entry +point and is what `.mcp.json` / `.vscode/mcp.json` register as the +`rpg-tools` command — no absolute paths in the config, no per-machine +edits. + ## Network and Release Options ```bash diff --git a/RPG-Kit/docs/commands.md b/RPG-Kit/docs/commands.md index e94858f..1176dde 100644 --- a/RPG-Kit/docs/commands.md +++ b/RPG-Kit/docs/commands.md @@ -92,8 +92,8 @@ Generate and iteratively refine the feature tree from `.rpgkit/data/feature_spec **Current workflow:** -1. **Validate status** — runs `.rpgkit/scripts/feature_build_validation.py` to verify that `feature_spec.json` exists and decide whether this is a first build or an expansion. -2. **Build or expand** — runs `.rpgkit/scripts/feature_build.py --mode step1`. +1. **Validate status** — runs `rpgkit script feature_build_validation.py` to verify that `feature_spec.json` exists and decide whether this is a first build or an expansion. +2. **Build or expand** — runs `rpgkit script feature_build.py --mode step1`. - If `feature_build.json` does not exist, RPG-Kit builds the feature tree from the specification and iterates until requirements are covered. - If `feature_build.json` already exists, RPG-Kit switches to beyond-spec expansion mode and adds production-relevant features not described by the original spec. 3. **Review** — validates coverage, duplicates, and MIU constraints. Coverage review uses a default threshold of `98.0` and up to `3` review iterations. @@ -201,9 +201,9 @@ Build inter-component data flow as a directed acyclic graph (DAG). 2. **Iteration choice** — asks for max iterations: - `Y` uses the default of 5 iterations. - A number sets a custom iteration budget. -3. **DAG design** — runs `.rpgkit/scripts/build_data_flow.py --max-iterations `. -4. **Validation** — runs `.rpgkit/scripts/check_data_flow.py --verbose`. -5. **Visualization** — runs `.rpgkit/scripts/generate_viz.py` when a new data flow is built. +3. **DAG design** — runs `rpgkit script build_data_flow.py --max-iterations `. +4. **Validation** — runs `rpgkit script check_data_flow.py --verbose`. +5. **Visualization** — runs `rpgkit script generate_viz.py` when a new data flow is built. **Example:** @@ -356,9 +356,9 @@ This command is independent from `/rpgkit.feature_edit` and `/rpgkit.update_rpg` **Workflow:** -1. **Pre-check** — runs `.rpgkit/scripts/rpg_edit/validate.py --json` and stops if the RPG or dependency graph is unavailable. -2. **Locate target nodes** — runs `.rpgkit/scripts/rpg_edit/locate.py --query "" --json` and selects existing nodes or nearest parent nodes for new features. -3. **Analyze impact** — runs `.rpgkit/scripts/rpg_edit/impact.py --node-id ... --json` to identify affected nodes, callers, callees, and files. +1. **Pre-check** — runs `rpgkit script rpg_edit/validate.py --json` and stops if the RPG or dependency graph is unavailable. +2. **Locate target nodes** — runs `rpgkit script rpg_edit/locate.py --query "" --json` and selects existing nodes or nearest parent nodes for new features. +3. **Analyze impact** — runs `rpgkit script rpg_edit/impact.py --node-id ... --json` to identify affected nodes, callers, callees, and files. 4. **Optional visual reconnaissance** — for UI/layout/style edits, probes the app with the browser helper when available. 5. **Mandatory code reconnaissance** — reads affected files and searches related patterns before producing a plan. 6. **Generate and confirm plan** — writes `.rpgkit/data/rpg_edit_plan.json` and asks the user to apply, cancel, revise, or inspect a node. @@ -392,8 +392,8 @@ Encode the current repository into an RPG from scratch. **Process:** -1. **Pre-check** — runs `.rpgkit/scripts/rpg_encoder/check_encode.py --json`. -2. **Full encode** — runs `.rpgkit/scripts/rpg_encoder/run_encode.py --json`. +1. **Pre-check** — runs `rpgkit script rpg_encoder/check_encode.py --json`. +2. **Full encode** — runs `rpgkit script rpg_encoder/run_encode.py --json`. 3. **Next steps** — suggests `/rpgkit.update_rpg` for incremental updates and MCP tools for exploration. If `rpg.json` already exists, the command asks whether to full re-encode, switch to `/rpgkit.update_rpg`, or quit. @@ -418,9 +418,9 @@ Under normal use, RPG-Kit installs a post-commit hook that updates the RPG in th **Process:** -1. **Pre-check** — runs `.rpgkit/scripts/rpg_encoder/check_encode.py --json` and stops if `rpg.json` is missing or corrupt. +1. **Pre-check** — runs `rpgkit script rpg_encoder/check_encode.py --json` and stops if `rpg.json` is missing or corrupt. 2. **Commit baseline check** — verifies `HEAD~1` exists. If there is no previous commit, run `/rpgkit.encode` instead. -3. **Incremental update** — runs `.rpgkit/scripts/update_graphs.py update-rpg --json`, comparing the current workspace against `HEAD~1`, the same baseline used by the hook. +3. **Incremental update** — runs `rpgkit script update_graphs.py update-rpg --json`, comparing the current workspace against `HEAD~1`, the same baseline used by the hook. 4. **Report result** — displays node/edge deltas, functional areas, alignment status, and output path. Use this command when: diff --git a/RPG-Kit/docs/project-structure.md b/RPG-Kit/docs/project-structure.md index d950c53..84daa31 100644 --- a/RPG-Kit/docs/project-structure.md +++ b/RPG-Kit/docs/project-structure.md @@ -42,54 +42,16 @@ my-project/ └── .rpgkit/ ├── config.toml # Workspace AI / config (committed). See docs/configuration.md ├── .source # Provisioning channel marker: "bundle" or "legacy" - ├── scripts/ # Pipeline scripts and support packages - │ ├── feature_spec_to_json.py # Feature specification - │ ├── feature_build.py - │ ├── feature_build_validation.py - │ ├── feature_refactor.py - │ ├── feature_refactor_validation.py - │ ├── feature_edit.py - │ ├── feature_edit_validation.py - │ ├── build_skeleton.py # RPG construction - │ ├── check_skeleton.py - │ ├── summary_skeleton.py - │ ├── build_data_flow.py - │ ├── check_data_flow.py - │ ├── generate_viz.py - │ ├── design_base_classes.py - │ ├── check_base_classes.py - │ ├── design_interfaces.py - │ ├── check_interfaces.py - │ ├── plan_tasks.py - │ ├── check_tasks.py - │ ├── init_codebase.py # Code generation - │ ├── run_batch.py # TDD batch executor, final test, global review - │ ├── check_code_gen.py - │ ├── update_graphs.py # Incremental RPG and dependency graph updates - │ ├── mcp_server.py # rpg-tools MCP server - │ ├── code_gen/ # Code generation subpackage - │ ├── common/ # Shared utilities and path definitions - │ ├── feature/ # Feature processing - │ ├── func_design/ # Function/interface design agents - │ ├── skeleton/ # Skeleton building - │ ├── rpg/ # RPG models, services, graph query engine - │ ├── rpg_edit/ # Surgical RPG/code edit pipeline - │ └── rpg_encoder/ # Reverse encoder - │ ├── check_encode.py # Pre-check rpg.json state - │ ├── run_encode.py # Full encode - │ ├── run_update_rpg.py # Incremental update implementation - │ ├── rpg_encoding.py # RPG encoding pipeline - │ ├── rpg_evolution.py # Incremental RPG evolution - │ ├── semantic_parsing.py # Semantic feature extraction - │ └── refactor_tree.py # Feature tree refactoring - ├── data/ # Runtime artifacts, populated by commands - ├── logs/ # Per-stage logs - └── reports/ # Review and diagnostic reports when generated + ├── data/ # Runtime artifacts, populated by commands + ├── logs/ # Per-stage logs + └── reports/ # Review and diagnostic reports when generated ``` +> Pipeline scripts (formerly materialised into `.rpgkit/scripts/`) now live inside the installed `rpgkit-cli` wheel under `rpgkit_cli/core_pack/scripts/` and are invoked via the global [`rpgkit script `](cli-reference.md) command. They are no longer copied into each workspace, so `rpgkit init` produces a much smaller footprint and a single source of truth per CLI install. + The agent configuration directory varies by the selected AI assistant and release package. For the verified CLI path, `--ai claude` installs `.claude/commands/`, while `--ai copilot` installs `.github/agents/`, `.github/prompts/`, and `.vscode/mcp.json`. -Command definitions are installed into the AI-agent-specific folder. Normal users should not need to edit `.rpgkit/scripts/` or `.rpgkit/data/` manually. +Command definitions are installed into the AI-agent-specific folder. Normal users should not need to edit `.rpgkit/data/` manually. ## Generated Data Files From e3f212c56d3d5f6ca28519dabe7a1488178bc84b Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Tue, 19 May 2026 17:41:57 +0800 Subject: [PATCH 10/31] fix(plan-02): post-implementation review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit pass on Plan 02 surface; address loose ends. - templates/commands: sed prose backtick refs (5 files, 5 sites) — 'Run the script `.rpgkit/scripts/X.py`' was missed by the previous python3-prefix sed pass and would mislead AI agents into trying a filesystem path that no longer exists. - scripts/**.py: change all 'cmd_for("X.py")' inside f-strings to 'cmd_for(\'X.py\')'. PEP 701 nested same-kind quotes work on Python 3.12+ but trip many syntax highlighters / linters and are fragile. Single quotes inside the f-string is the safer style. - scripts/mcp_server.py: refresh module docstring to mention the rpgkit-mcp console-script entry instead of the legacy workspace path. - scripts/feature_spec_to_json.py: usage docstring uses 'rpgkit script' form. - src/rpgkit_cli/__init__.py: drop the deprecated 'ensure_executable_scripts' no-op (no remaining callers); update one stale prose reference in _has_python_files docstring. E2E + full test suite pass at baseline (11 pre-existing failures, 883 passing). --- RPG-Kit/scripts/check_code_gen.py | 18 ++++++------- RPG-Kit/scripts/code_gen/result_builders.py | 18 ++++++------- RPG-Kit/scripts/feature_spec_to_json.py | 2 +- RPG-Kit/scripts/init_codebase.py | 6 ++--- RPG-Kit/scripts/mcp_server.py | 14 ++++++----- RPG-Kit/scripts/rpg_edit/code.py | 2 +- RPG-Kit/scripts/rpg_edit/review.py | 2 +- RPG-Kit/scripts/run_batch.py | 4 +-- RPG-Kit/scripts/smoke_test.py | 2 +- RPG-Kit/src/rpgkit_cli/__init__.py | 25 +++---------------- RPG-Kit/templates/commands/build_data_flow.md | 2 +- RPG-Kit/templates/commands/build_skeleton.md | 2 +- .../templates/commands/design_base_classes.md | 2 +- RPG-Kit/templates/commands/feature_edit.md | 2 +- .../templates/commands/feature_refactor.md | 2 +- 15 files changed, 43 insertions(+), 60 deletions(-) diff --git a/RPG-Kit/scripts/check_code_gen.py b/RPG-Kit/scripts/check_code_gen.py index 2abb152..66bbee0 100644 --- a/RPG-Kit/scripts/check_code_gen.py +++ b/RPG-Kit/scripts/check_code_gen.py @@ -156,8 +156,8 @@ def determine_state( "remaining": total_tasks } result["next_action"] = ( - f"Run: {cmd_for("init_codebase.py")} --json to initialize the repository, " - f"then run: {cmd_for("run_batch.py")} --next --json to start the first batch." + f"Run: {cmd_for('init_codebase.py')} --json to initialize the repository, " + f"then run: {cmd_for('run_batch.py')} --next --json to start the first batch." ) result["workflow_hint"] = ( "run_batch.py --next dispatches a sub-agent that autonomously " @@ -263,7 +263,7 @@ def determine_state( result["auto_recovery_error"] = str(e) result["next_action"] = ( f"Tests passed but auto-recovery failed ({e}). " - f"Run: {cmd_for("run_batch.py")} --resume --json to retry." + f"Run: {cmd_for('run_batch.py')} --resume --json to retry." ) return result else: @@ -281,14 +281,14 @@ def determine_state( if phase == "failed": result["next_action"] = ( f"Batch {current_batch_id} has failed. " - f"Run: {cmd_for("run_batch.py")} --retry {current_batch_id} --json " - f"to retry, or {cmd_for("run_batch.py")} --next --json to skip " + f"Run: {cmd_for('run_batch.py')} --retry {current_batch_id} --json " + f"to retry, or {cmd_for('run_batch.py')} --next --json to skip " f"it and move on." ) else: result["next_action"] = ( f"Resume the current batch (phase: {phase}). " - f"Run: {cmd_for("run_batch.py")} --resume --json" + f"Run: {cmd_for('run_batch.py')} --resume --json" ) result["workflow_hint"] = ( "run_batch.py --resume dispatches a sub-agent that autonomously " @@ -308,7 +308,7 @@ def determine_state( result["message"] = f"Ready to continue ({remaining} tasks remaining)" result["next_batch"] = next_batch result["next_action"] = ( - f"Run: {cmd_for("run_batch.py")} --next --json " + f"Run: {cmd_for('run_batch.py')} --next --json " f"to start the next batch." ) result["workflow_hint"] = ( @@ -345,11 +345,11 @@ def determine_state( if not ft_passed: result["next_action"] = ( - f"Run: {cmd_for("run_batch.py")} --final-test --json" + f"Run: {cmd_for('run_batch.py')} --final-test --json" ) elif not gr_passed: result["next_action"] = ( - f"Final test passed. Run: {cmd_for("run_batch.py")} --global-review --json" + f"Final test passed. Run: {cmd_for('run_batch.py')} --global-review --json" ) else: result["next_action"] = ( diff --git a/RPG-Kit/scripts/code_gen/result_builders.py b/RPG-Kit/scripts/code_gen/result_builders.py index 96fb62b..2709a42 100644 --- a/RPG-Kit/scripts/code_gen/result_builders.py +++ b/RPG-Kit/scripts/code_gen/result_builders.py @@ -25,7 +25,7 @@ def _error(message: str, scripts: str) -> Dict[str, Any]: return { "success": False, "error": message, - "next_action": f"Fix the issue, then run: {cmd_for("run_batch.py")} --next --json", + "next_action": f"Fix the issue, then run: {cmd_for('run_batch.py')} --next --json", } @@ -40,12 +40,12 @@ def _all_done(global_state: CodeGenState, tasks_path: Path, scripts: str) -> Dic msg = f"All batches processed: {completed} completed, {failed} failed out of {total}." next_act = ( f"Some batches failed. You can retry them with: " - f"{cmd_for("run_batch.py")} --retry --json, " - f"or run final validation: {cmd_for("run_batch.py")} --final-test --json" + f"{cmd_for('run_batch.py')} --retry --json, " + f"or run final validation: {cmd_for('run_batch.py')} --final-test --json" ) else: msg = f"All {completed} batches completed successfully!" - next_act = f"Run final validation: {cmd_for("run_batch.py")} --final-test --json" + next_act = f"Run final validation: {cmd_for('run_batch.py')} --final-test --json" return { "success": True, @@ -101,10 +101,10 @@ def _success_result( }, "next_action": ( f"Batch completed. {remaining} tasks remaining. " - f"Run: {cmd_for("run_batch.py")} --next --json" + f"Run: {cmd_for('run_batch.py')} --next --json" if remaining > 0 else - f"All batches done! Run: {cmd_for("run_batch.py")} --final-test --json\n" - f"Then run: {cmd_for("run_batch.py")} --global-review --json" + f"All batches done! Run: {cmd_for('run_batch.py')} --final-test --json\n" + f"Then run: {cmd_for('run_batch.py')} --global-review --json" ), } @@ -147,7 +147,7 @@ def _failure_result( "next_action": ( f"Batch failed after {len(attempts)} attempts. " f"Branch '{batch_state.branch_name}' preserved for inspection. " - f"Retry: {cmd_for("run_batch.py")} --retry {batch_id} --json, " - f"or continue: {cmd_for("run_batch.py")} --next --json" + f"Retry: {cmd_for('run_batch.py')} --retry {batch_id} --json, " + f"or continue: {cmd_for('run_batch.py')} --next --json" ), } diff --git a/RPG-Kit/scripts/feature_spec_to_json.py b/RPG-Kit/scripts/feature_spec_to_json.py index 21a0f68..157c75f 100644 --- a/RPG-Kit/scripts/feature_spec_to_json.py +++ b/RPG-Kit/scripts/feature_spec_to_json.py @@ -8,7 +8,7 @@ Output: A structured JSON file with all parsed content. Usage: - python .rpgkit/scripts/feature_spec_to_json.py [--input-dir DIR] [--output FILE] [--no-evidence] + rpgkit script feature_spec_to_json.py [--input-dir DIR] [--output FILE] [--no-evidence] Arguments: --input-dir Directory containing feature_spec.md and features/ folder diff --git a/RPG-Kit/scripts/init_codebase.py b/RPG-Kit/scripts/init_codebase.py index 5ecc73f..dec717a 100644 --- a/RPG-Kit/scripts/init_codebase.py +++ b/RPG-Kit/scripts/init_codebase.py @@ -537,7 +537,7 @@ def init_codebase( "initialized_at": state.initialized_at, "suggestion": "Run run_batch.py to start codegen", "next_action": ( - f"Already initialized. Run: {cmd_for("run_batch.py")} --next --json " + f"Already initialized. Run: {cmd_for('run_batch.py')} --next --json " f"to start the next batch." ) } @@ -600,7 +600,7 @@ def init_codebase( "gitignore_created": False, "base_class_files": 0, "next_action": ( - f"Codebase already set up. Run: {cmd_for("run_batch.py")} --next --json " + f"Codebase already set up. Run: {cmd_for('run_batch.py')} --next --json " f"to start the first batch." ) } @@ -633,7 +633,7 @@ def init_codebase( "commit_hash": commit_hash, "message": "Repository initialized successfully" if not dry_run else "Dry run complete", "next_action": ( - f"Codebase initialized. Run: {cmd_for("run_batch.py")} --next --json " + f"Codebase initialized. Run: {cmd_for('run_batch.py')} --next --json " f"to start the first batch." ) if not dry_run else "Dry run complete. Re-run without --dry-run to apply changes." } diff --git a/RPG-Kit/scripts/mcp_server.py b/RPG-Kit/scripts/mcp_server.py index d0cb782..2fd93a1 100644 --- a/RPG-Kit/scripts/mcp_server.py +++ b/RPG-Kit/scripts/mcp_server.py @@ -10,14 +10,16 @@ - ``list_rpg_tree`` -- browse RPG feature tree structure The server communicates over stdio (the standard MCP transport for -CLI-based servers). It is designed to be deployed under -``/.rpgkit/scripts/`` by ``rpgkit init`` / ``rpgkit update``, -and registered automatically in ``.mcp.json`` (Claude) or -``.vscode/mcp.json`` (VS Code Copilot). +CLI-based servers). It ships inside the ``rpgkit-cli`` wheel and is +launched by MCP clients via the ``rpgkit-mcp`` console script (which +``.mcp.json`` / ``.vscode/mcp.json`` register as the ``rpg-tools`` +command — see ``rpgkit_cli.entries:mcp_main``). -Run directly:: +Run directly (for debugging):: - python /.rpgkit/scripts/mcp_server.py [--rpg-file PATH] + rpgkit-mcp [--rpg-file PATH] + # or equivalently: + rpgkit script mcp_server.py [--rpg-file PATH] """ import json diff --git a/RPG-Kit/scripts/rpg_edit/code.py b/RPG-Kit/scripts/rpg_edit/code.py index 843b6b0..3a69ba6 100644 --- a/RPG-Kit/scripts/rpg_edit/code.py +++ b/RPG-Kit/scripts/rpg_edit/code.py @@ -175,7 +175,7 @@ def _build_validation_cmds(code_changes: List[dict]) -> Tuple[str, str]: we still use absolute paths to keep the prompt cwd-agnostic — it must work no matter where the user runs the slash command from. """ - smoke = f"{cmd_for("smoke_test.py")} --json" + smoke = f"{cmd_for('smoke_test.py')} --json" patterns = _derive_test_files(code_changes) if patterns: diff --git a/RPG-Kit/scripts/rpg_edit/review.py b/RPG-Kit/scripts/rpg_edit/review.py index 7e7552a..5c617a7 100644 --- a/RPG-Kit/scripts/rpg_edit/review.py +++ b/RPG-Kit/scripts/rpg_edit/review.py @@ -344,7 +344,7 @@ def build_impact_review_prompt( tools_dir = WORKSPACE_ROOT / ".rpgkit" / "scripts" / "tools" browser_tool = str(tools_dir / "browser.py") gui_tool = str(tools_dir / "gui.py") - smoke_test_cmd = f"{cmd_for("smoke_test.py")} --json" + smoke_test_cmd = f"{cmd_for('smoke_test.py')} --json" # Start instructions depend on project type start_instructions = ( diff --git a/RPG-Kit/scripts/run_batch.py b/RPG-Kit/scripts/run_batch.py index 40f40fa..20b8a73 100644 --- a/RPG-Kit/scripts/run_batch.py +++ b/RPG-Kit/scripts/run_batch.py @@ -680,7 +680,7 @@ def run_batch( return _error( f"Tests pass but branch merge failed: {merge_error}. " f"Branch '{branch_name}' preserved. " - f"Retry: {cmd_for("run_batch.py")} --retry {batch_id} --json", + f"Retry: {cmd_for('run_batch.py')} --retry {batch_id} --json", scripts, ) state_complete_batch(batch_id, True, state_path, rpg_backup_path=rpg_backup) @@ -817,7 +817,7 @@ def run_batch( return _error( f"Tests passed but branch merge failed: {merge_error}. " f"Branch '{branch_name}' preserved. " - f"Retry: {cmd_for("run_batch.py")} --retry {batch_id} --json", + f"Retry: {cmd_for('run_batch.py')} --retry {batch_id} --json", scripts, ) diff --git a/RPG-Kit/scripts/smoke_test.py b/RPG-Kit/scripts/smoke_test.py index 9dfb7ea..86e3233 100644 --- a/RPG-Kit/scripts/smoke_test.py +++ b/RPG-Kit/scripts/smoke_test.py @@ -405,7 +405,7 @@ def main() -> int: scripts = get_scripts_dir() if not result.success: print("\n Fix the issues above, then re-run:") - print(f" {cmd_for("smoke_test.py")} --json") + print(f" {cmd_for('smoke_test.py')} --json") return 0 if result.success else 1 diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 9b013f0..6fc1bec 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -1525,10 +1525,9 @@ def _workspace_has_python_code(project_path: Path) -> bool: code) skip the prompt because the encoder would produce an empty graph and waste LLM tokens. - The walk prunes the ``.rpgkit`` directory in-place so we don't - accidentally count the runtime scripts we just extracted (every - workspace has ``.rpgkit/scripts/*.py`` after init). Common - boilerplate dirs (``.git``, ``.venv``, ``node_modules``, + The walk prunes the ``.rpgkit`` directory in-place so workspace + runtime state (``data/``, ``logs/``) doesn't influence the detection. + Common boilerplate dirs (``.git``, ``.venv``, ``node_modules``, ``__pycache__``) are pruned too — a ``*.py`` under any of them would not indicate user code. """ @@ -3351,20 +3350,6 @@ def _download_and_extract_release_zip( return project_path -def ensure_executable_scripts( - project_path: Path, tracker: StepTracker | None = None -) -> None: - """Deprecated no-op. - - Previously ensured POSIX execute bits on ``.rpgkit/scripts/**/*.sh``. - After plan 02, scripts live inside the installed wheel where the - exec bits are set at install time by the packaging tool, and the - workspace no longer hosts a scripts copy. Kept as a stub to keep - existing call sites simple; safe to remove in a future cleanup PR. - """ - return - - def ensure_rpgkit_runtime_dirs( project_path: Path, tracker: StepTracker | None = None ) -> None: @@ -3732,8 +3717,6 @@ def init( # the right sub-agent. Plan §3 / decision 13. _write_workspace_config(project_path, selected_ai) - ensure_executable_scripts(project_path, tracker=tracker) - # Materialize .gitignore *before* MCP/hook generation so the # files those steps create (.vscode/mcp.json, .vscode/tasks.json, # .mcp.json) are ignored from the moment they hit disk. This is @@ -4225,8 +4208,6 @@ def update( # user customisations on re-update). _write_workspace_config(project_path, selected_ai) - ensure_executable_scripts(project_path, tracker=tracker) - # Pre-create runtime directories so stage prompts that redirect # to .rpgkit/logs/.log don't fail when the folder is # missing (e.g. user removed it, or workspace was created by an diff --git a/RPG-Kit/templates/commands/build_data_flow.md b/RPG-Kit/templates/commands/build_data_flow.md index 909b162..7c6635d 100644 --- a/RPG-Kit/templates/commands/build_data_flow.md +++ b/RPG-Kit/templates/commands/build_data_flow.md @@ -69,7 +69,7 @@ Run the script `rpgkit script check_data_flow.py` to verify the current state. 1. Display the following prompt and wait for user confirmation: ```text - Description: Run the script `.rpgkit/scripts/build_data_flow.py` to: + Description: Run the script `rpgkit script build_data_flow.py` to: - Design inter-component data flow as a DAG - Generate subtree processing order diff --git a/RPG-Kit/templates/commands/build_skeleton.md b/RPG-Kit/templates/commands/build_skeleton.md index 6d1e88d..5b1a550 100644 --- a/RPG-Kit/templates/commands/build_skeleton.md +++ b/RPG-Kit/templates/commands/build_skeleton.md @@ -57,7 +57,7 @@ Run the script `rpgkit script check_skeleton.py` to verify the current state. 1. Display the following prompt and wait for user confirmation: ```text - Description: Run the script `.rpgkit/scripts/build_skeleton.py` to: + Description: Run the script `rpgkit script build_skeleton.py` to: - Step 1: Design directory structure for components - Step 2: Assign features to Python files diff --git a/RPG-Kit/templates/commands/design_base_classes.md b/RPG-Kit/templates/commands/design_base_classes.md index f145056..ad66071 100644 --- a/RPG-Kit/templates/commands/design_base_classes.md +++ b/RPG-Kit/templates/commands/design_base_classes.md @@ -52,7 +52,7 @@ Run the script `rpgkit script check_base_classes.py` to verify the current state 1. Display the following prompt and wait for user confirmation: ```text - Description: Run the script `.rpgkit/scripts/design_base_classes.py` to: + Description: Run the script `rpgkit script design_base_classes.py` to: - Design functional base classes (behavioral abstractions) - Design global data structures (shared data formats) diff --git a/RPG-Kit/templates/commands/feature_edit.md b/RPG-Kit/templates/commands/feature_edit.md index 108fa65..7f7831c 100644 --- a/RPG-Kit/templates/commands/feature_edit.md +++ b/RPG-Kit/templates/commands/feature_edit.md @@ -73,7 +73,7 @@ Inspect the `type` field in the output: Display the following prompt and wait for user confirmation: ```markdown -The script `.rpgkit/scripts/feature_edit.py` will be executed to edit the feature tree based on your instructions. +The script `rpgkit script feature_edit.py` will be executed to edit the feature tree based on your instructions. **File:** `.rpgkit/data/feature_tree.json` diff --git a/RPG-Kit/templates/commands/feature_refactor.md b/RPG-Kit/templates/commands/feature_refactor.md index 19df6b9..417d7a2 100644 --- a/RPG-Kit/templates/commands/feature_refactor.md +++ b/RPG-Kit/templates/commands/feature_refactor.md @@ -33,7 +33,7 @@ name: rpgkit.feature_refactor 1. Must display the following information and prompt the user to confirm the maximum number of iterations (default: 10). ```markdown - **description**: Run the script `.rpgkit/scripts/feature_refactor.py` to perform a two-step process: + **description**: Run the script `rpgkit script feature_refactor.py` to perform a two-step process: - Step 1: Plan the structure and number of subtrees - Step 2: Iteratively assign features to the planned subtrees From 2bd355bf5de3696a46e6d006ab5cfe465881e605 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Tue, 19 May 2026 18:04:38 +0800 Subject: [PATCH 11/31] fix(plan-02): route remaining workspace-script lookups via packaged dir Two follow-ups from running 'rpgkit init .' on a real workspace: 1. Optional initial-encode kickoff (the 'Run the encoder now?' prompt at end of 'rpgkit init') still resolved the encoder via '$workspace/.rpgkit/scripts/rpg_encoder/run_encode.py'. After plan 02 the workspace no longer has that subtree, so the kickoff always printed 'Encoder script not found' and aborted. Fall back to '_assets.scripts_dir()' (the packaged location) when the workspace copy is absent. 2. The VS Code 'folderOpen' task in .vscode/tasks.json was still invoking sys.executable against the workspace 'update_graphs.py' path. Rewrite to 'command: rpgkit, args: [script, update_graphs.py, status]' so it works against the globally-installed CLI. Updated test_install_copilot_hooks_writes_folder_open_task assertions for the new task shape. --- RPG-Kit/src/rpgkit_cli/__init__.py | 32 +++++++++++++++++++---------- RPG-Kit/tests/test_hooks_install.py | 6 +++++- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 6fc1bec..213ce00 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -1705,11 +1705,21 @@ def _run_initial_encode(project_path: Path) -> bool: """ encoder = project_path / ".rpgkit" / "scripts" / "rpg_encoder" / "run_encode.py" if not encoder.is_file(): - console.print( - f"[yellow]Encoder script not found at {encoder}; " - f"run [cyan]/rpgkit.encode[/] in your AI agent later.[/yellow]" - ) - return False + # New layout (plan 02): scripts live inside the installed wheel + # under ``rpgkit_cli/core_pack/scripts/``. Resolve the encoder + # from there so the optional initial-encode kickoff works after + # ``rpgkit init`` — which no longer copies scripts into the + # workspace. + from . import _assets + candidate = _assets.scripts_dir() / "rpg_encoder" / "run_encode.py" + if candidate.is_file(): + encoder = candidate + else: + console.print( + f"[yellow]Encoder script not found at {candidate}; " + f"run [cyan]/rpgkit.encode[/] in your AI agent later.[/yellow]" + ) + return False log_dir = project_path / ".rpgkit" / "logs" try: @@ -2563,15 +2573,15 @@ def _install_copilot_hooks(project_path: Path) -> None: # Backup is best-effort; never block installation on it. pass - update_script = str( - (project_path / ".rpgkit" / "scripts" / "update_graphs.py").resolve() - ) - rpg_status_task = { "label": "RPG-Kit: load status", "type": "shell", - "command": sys.executable, - "args": [update_script, "status"], + # Invoke the globally-installed CLI rather than a workspace + # script copy (which no longer exists after plan 02). Same + # rationale as the git-hook bodies: portable command name, + # auto-tracks the installed wheel's scripts. + "command": "rpgkit", + "args": ["script", "update_graphs.py", "status"], "presentation": { "echo": False, "reveal": "silent", diff --git a/RPG-Kit/tests/test_hooks_install.py b/RPG-Kit/tests/test_hooks_install.py index 2b2aa08..8a7110c 100644 --- a/RPG-Kit/tests/test_hooks_install.py +++ b/RPG-Kit/tests/test_hooks_install.py @@ -151,8 +151,12 @@ def test_install_copilot_hooks_writes_folder_open_task(project): t = tasks["tasks"][0] assert t["label"] == "RPG-Kit: load status" assert t["runOptions"] == {"runOn": "folderOpen"} + # Task now invokes the global ``rpgkit`` CLI; args carry the + # dispatcher subcommand + script relpath, with ``status`` last. + assert t["command"] == "rpgkit" + assert t["args"][0] == "script" + assert t["args"][1] == "update_graphs.py" assert t["args"][-1] == "status" - assert t["args"][0].endswith("update_graphs.py") # Status output should appear silently — we don't want it stealing focus. assert t["presentation"]["reveal"] == "silent" # NOTE: .gitignore management was moved to `_setup_gitignore` (called From 454c91775923b1e7e4f1ed6395da0f241a2bab11 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Tue, 19 May 2026 18:09:24 +0800 Subject: [PATCH 12/31] fix(plan-02): sweep remaining script-path references (real bugs + docstrings) Full audit pass turned up a few more cases that needed updating after 'rpgkit init .' on a real workspace exposed gaps. Functional bugs: - scripts/rpg_edit/review.py: the review-stage prompt embedded '$WORKSPACE_ROOT/.rpgkit/scripts/tools/{browser,gui}.py' paths into the LLM instructions. Since the workspace no longer hosts the scripts dir, the AI would invoke a non-existent file. Switch to 'cmd_for("tools/browser.py")' / 'cmd_for("tools/gui.py")' and drop the leading 'python' from each invocation in the prompt template (it's already rooted by 'rpgkit script'). - scripts/code_gen/batch_prompts.py: the sub-agent guard-rail rule 'You MUST NOT run any .rpgkit/scripts/*.py commands' now reads 'run any rpgkit script ... or rpgkit-mcp commands' so the rule still covers what it intended to prohibit. Docstring / comment cleanup: - scripts/rpg_edit/__init__.py: module docstring example. - scripts/update_graphs.py: post-commit lock comment example. - Removed unused 'WORKSPACE_ROOT' import from rpg_edit/review.py. Tests baseline preserved (11 pre-existing failures, 883 passing). --- RPG-Kit/scripts/code_gen/batch_prompts.py | 4 ++-- RPG-Kit/scripts/rpg_edit/__init__.py | 2 +- RPG-Kit/scripts/rpg_edit/review.py | 25 ++++++++++++----------- RPG-Kit/scripts/update_graphs.py | 2 +- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/RPG-Kit/scripts/code_gen/batch_prompts.py b/RPG-Kit/scripts/code_gen/batch_prompts.py index 29a4cee..b74a2c1 100644 --- a/RPG-Kit/scripts/code_gen/batch_prompts.py +++ b/RPG-Kit/scripts/code_gen/batch_prompts.py @@ -208,7 +208,7 @@ [FAIL] You MUST NOT: - Modify or read files under `.rpgkit/` -- Run any `.rpgkit/scripts/*.py` commands +- Run any `rpgkit script ...` or `rpgkit-mcp` commands - Run arbitrary shell commands beyond pytest/pip/git listed above - Install packages that are not genuinely needed by the source code - Delete files that are not part of your task @@ -311,7 +311,7 @@ [FAIL] You MUST NOT: - Modify existing source code or test files - Modify or read files under `.rpgkit/` -- Run any `.rpgkit/scripts/*.py` commands +- Run any `rpgkit script ...` or `rpgkit-mcp` commands ## Task Details diff --git a/RPG-Kit/scripts/rpg_edit/__init__.py b/RPG-Kit/scripts/rpg_edit/__init__.py index a831f09..11b899a 100644 --- a/RPG-Kit/scripts/rpg_edit/__init__.py +++ b/RPG-Kit/scripts/rpg_edit/__init__.py @@ -1,7 +1,7 @@ """RPG edit pipeline — CLI entry points for the ``/rpgkit.rpg_edit`` flow. Each module is a standalone script meant to be invoked as -``python3 .rpgkit/scripts/rpg_edit/.py [args]``. They share the +``rpgkit script rpg_edit/.py [args]``. They share the ``common.paths`` / ``rpg`` / ``run_batch`` infrastructure that lives at ``scripts/`` and add ``scripts/`` (i.e. ``parent.parent``) to ``sys.path`` on import so the relative-import path stays predictable regardless of cwd. diff --git a/RPG-Kit/scripts/rpg_edit/review.py b/RPG-Kit/scripts/rpg_edit/review.py index 5c617a7..e0b071b 100644 --- a/RPG-Kit/scripts/rpg_edit/review.py +++ b/RPG-Kit/scripts/rpg_edit/review.py @@ -34,7 +34,7 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import WORKSPACE_ROOT, REPO_DIR, cmd_for # noqa: E402 +from common.paths import REPO_DIR, cmd_for # noqa: E402 logger = logging.getLogger(__name__) @@ -105,8 +105,8 @@ For **web apps**, use `inspect` on EVERY affected route to capture screenshots and saved HTML: ```bash -python $BROWSER_TOOL inspect http://localhost:/ -python $BROWSER_TOOL inspect http://localhost:/ +$BROWSER_TOOL inspect http://localhost:/ +$BROWSER_TOOL inspect http://localhost:/ ``` Read the saved HTML files to understand the full page content, CSS layout, and element structure. Check for: @@ -119,7 +119,7 @@ Don't just view pages — **interact** with them like a real user: ```bash -python $BROWSER_TOOL run-script http://localhost:/ --script ' +$BROWSER_TOOL run-script http://localhost:/ --script ' page.click("a:has-text(\\"Some Link\\")") page.wait_for_load_state("networkidle") ' @@ -128,10 +128,10 @@ For **GUI apps**, use the GUI tool: ```bash -python $GUI_TOOL start-display -python $GUI_TOOL launch "python main.py" --wait 3 -python $GUI_TOOL status -python $GUI_TOOL screenshot +$GUI_TOOL start-display +$GUI_TOOL launch "python main.py" --wait 3 +$GUI_TOOL status +$GUI_TOOL screenshot ``` Click every relevant button, fill forms, and screenshot after each action. @@ -340,10 +340,11 @@ def build_impact_review_prompt( pattern = " or ".join(test_patterns) pytest_cmd += f' -k "{pattern}" --timeout=30' - # Use absolute paths so the prompt is cwd-agnostic. - tools_dir = WORKSPACE_ROOT / ".rpgkit" / "scripts" / "tools" - browser_tool = str(tools_dir / "browser.py") - gui_tool = str(tools_dir / "gui.py") + # Tool invocations route through the global ``rpgkit`` CLI (the + # scripts no longer live in the workspace). See ``rpgkit script`` + # in docs/cli-reference.md. + browser_tool = cmd_for("tools/browser.py") + gui_tool = cmd_for("tools/gui.py") smoke_test_cmd = f"{cmd_for('smoke_test.py')} --json" # Start instructions depend on project type diff --git a/RPG-Kit/scripts/update_graphs.py b/RPG-Kit/scripts/update_graphs.py index 82edd80..47a3a2a 100644 --- a/RPG-Kit/scripts/update_graphs.py +++ b/RPG-Kit/scripts/update_graphs.py @@ -386,7 +386,7 @@ def cmd_update_rpg( Designed for post-commit background invocation via ``setsid``:: setsid env -u GIT_INDEX_FILE -u GIT_DIR sh -c \ - "cd ; python update_graphs.py update-rpg --json >> log 2>&1" & + "cd ; rpgkit script update_graphs.py update-rpg --json >> log 2>&1" & Requires: - rpg.json exists (encode has been run) From 98d25e33085af4aa01b6953f7a24c868388804e3 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Wed, 20 May 2026 00:07:01 +0800 Subject: [PATCH 13/31] feat(cli): auto-snapshot .rpgkit/ via private inner git (Plan 03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every successful (or failed) 'rpgkit script ' invocation now commits the current state of .rpgkit/ to a dedicated repo at .rpgkit/.git/, giving users (and the e2e test runner) a free 'git log' / 'git diff' between any two pipeline stages. New module rpgkit_cli._inner_git holds all the logic: - find_workspace_root() walk up from cwd for .rpgkit/ - ensure_inner_git() create .rpgkit/.git + initial commit - auto_commit_after_script() snapshot after a 'rpgkit script' call - categorise_script() derive [] prefix - should_skip_script() skip check_* / *_validation* / mcp_server - snapshot_count() for 'rpgkit version' display CLI surface: - rpgkit init gains --no-rpgkit-git (default OFF = inner git ON) - rpgkit update gains --no-rpgkit-git; backfills inner git for pre-plan-03 workspaces, leaves pre-existing repos untouched. - rpgkit script auto-commits after the child exits; commit message: '[] ' with a ' — FAILED (exit N)' suffix when the child failed. - rpgkit version gains 'Inner git: N snapshots' line when present. Commit identity uses per-call -c user.email/user.name (rpgkit-snapshot ) — never writes to global git config. Concurrent locks (post-commit hook background worker) trigger a 1s retry then silent skip; the next successful commit folds in any missed changes. Plan: plans/03-auto-snapshot-inner-git.md (local) Test baseline preserved (11 pre-existing failures, 883 passing). --- RPG-Kit/src/rpgkit_cli/__init__.py | 103 +++++++++ RPG-Kit/src/rpgkit_cli/_inner_git.py | 301 +++++++++++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 RPG-Kit/src/rpgkit_cli/_inner_git.py diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 213ce00..7456f1e 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -3508,6 +3508,18 @@ def init( "prompt and run, or --no-encode to skip the prompt and not run." ), ), + no_rpgkit_git: bool = typer.Option( + False, + "--no-rpgkit-git", + help=( + "Skip initialising a private git repository inside .rpgkit/ " + "(see plan 03). Default is ON: rpgkit init seeds .rpgkit/.git " + "so every subsequent `rpgkit script` invocation auto-snapshots " + "the workspace state, letting you `git log` / `git diff` " + "between pipeline stages without extra tooling. This flag " + "disables the feature for the current init only." + ), + ), ): """Initialize a new RPG-Kit project from the latest template. @@ -3970,6 +3982,29 @@ def init( console.print() console.print(permissions_hint) + # Initialise the private snapshot repo inside .rpgkit/. Done BEFORE + # the optional initial encode so the encoder's output, if it runs, + # becomes a fresh commit on top of the [init] baseline — perfect + # diff target. Plan 03. + if not no_rpgkit_git: + from . import _inner_git + from importlib.metadata import version as _pkg_version, PackageNotFoundError + try: + ver = _pkg_version("rpgkit-cli") + except PackageNotFoundError: + ver = "dev" + channel = "legacy" if legacy_download else "bundle" + script_label = script_type if script_type else "sh" + ai_label = selected_ai if selected_ai else "?" + if _inner_git.ensure_inner_git( + project_path, + initial_msg=f"[init] v{ver} \u2014 {ai_label}/{script_label}, {channel} channel", + ): + console.print( + "[dim]Inner snapshot repo initialised at " + "[cyan].rpgkit/.git[/cyan] \u2014 `cd .rpgkit && git log` to inspect.[/dim]" + ) + # Final step: optionally build the initial RPG by running the # encoder. Skipped silently for empty workspaces / non-tty / when # the user passes --no-encode. @@ -4031,6 +4066,17 @@ def update( "assets are used. Requires network." ), ), + no_rpgkit_git: bool = typer.Option( + False, + "--no-rpgkit-git", + help=( + "Skip backfilling the private snapshot repo at .rpgkit/.git " + "for workspaces created before plan 03. Default is ON: if " + "the inner repo is missing, `rpgkit update` creates it and " + "commits a catch-up snapshot. Pre-existing inner repos are " + "never touched." + ), + ), ): """Update RPG-Kit template files in an existing project to the latest version. @@ -4303,6 +4349,25 @@ def update( f"command definitions in [cyan]{project_path}[/cyan][/dim]" ) + # Plan 03: backfill inner snapshot repo for workspaces created before + # this feature shipped. Idempotent — does nothing if .rpgkit/.git + # already exists, and silently noops if --no-rpgkit-git was passed. + if not no_rpgkit_git: + from . import _inner_git + from importlib.metadata import version as _pkg_version, PackageNotFoundError + try: + ver = _pkg_version("rpgkit-cli") + except PackageNotFoundError: + ver = "dev" + if _inner_git.ensure_inner_git( + project_path, + initial_msg=f"[update] v{ver} \u2014 catch-up snapshot", + ): + console.print( + "[dim]Initialised inner snapshot repo at " + "[cyan].rpgkit/.git[/cyan] for this workspace.[/dim]" + ) + @app.command( context_settings={ @@ -4382,6 +4447,29 @@ def script( cmd = [sys.executable, str(path), *ctx.args] proc = subprocess.run(cmd, env=env) + + # Plan 03: snapshot the current state of .rpgkit/ into the inner git + # repo so users can `git log` / `git diff` between pipeline stages. + # No-op (silently) when the script is read-only (check_*, *_validation), + # the inner repo is absent (--no-rpgkit-git on init), or git is busy. + # + # Use the *resolved* path (always carries .py) for the commit message + # so `rpgkit script smoke_test` and `rpgkit script smoke_test.py` + # produce identical history entries. + from . import _inner_git, _assets + ws_root = _inner_git.find_workspace_root() + if ws_root is not None: + try: + commit_relpath = str(path.relative_to(_assets.scripts_dir())).replace("\\", "/") + except ValueError: + commit_relpath = relpath.replace("\\", "/") + _inner_git.auto_commit_after_script( + ws_root, + commit_relpath, + list(ctx.args), + proc.returncode, + ) + raise typer.Exit(proc.returncode) @@ -4571,6 +4659,21 @@ def version(): info_table.add_row("Architecture", platform.machine()) info_table.add_row("OS Version", platform.version()) + # Plan 03: surface the inner-snapshot repo state when present. + try: + from . import _inner_git + ws = _inner_git.find_workspace_root() + if ws is not None: + count = _inner_git.snapshot_count(ws) + if count is not None: + info_table.add_row("", "") + info_table.add_row( + "Inner git", + f"{count} snapshots (cd {ws.name}/.rpgkit && git log)", + ) + except Exception: + pass + panel = Panel( info_table, title="[bold cyan]RPG-Kit CLI Information[/bold cyan]", diff --git a/RPG-Kit/src/rpgkit_cli/_inner_git.py b/RPG-Kit/src/rpgkit_cli/_inner_git.py new file mode 100644 index 0000000..0088b45 --- /dev/null +++ b/RPG-Kit/src/rpgkit_cli/_inner_git.py @@ -0,0 +1,301 @@ +"""Inner-git snapshotting for ``.rpgkit/``. + +Plan 03 — every successful (or failed) ``rpgkit script `` invocation +auto-commits the current state of ``.rpgkit/`` to a dedicated repo at +``.rpgkit/.git/``. Lets users `git log` / `git diff` between pipeline +stages without writing any extra tooling. + +Design choices (see plans/03-auto-snapshot-inner-git.md): + +* No global ``git config`` writes — every commit uses per-call + ``-c user.email`` / ``-c user.name`` so the user's identity is + untouched. +* Concurrent commits (background post-commit hook vs foreground script) + are handled by a one-shot retry on ``index.lock`` failure, then a + silent skip. Data is never lost: the next successful commit folds + in whatever the dropped one would have captured. +* Failures are committed too, tagged ``— FAILED (exit N)`` so the + history shows what changed pre-failure. +* Check / validation scripts are skipped (they're read-only and would + otherwise spam the history). + +All public functions swallow their own exceptions — this module must +never be a reason ``rpgkit script`` itself fails. +""" + +from __future__ import annotations + +import shlex +import subprocess +import time +from pathlib import Path +from typing import Optional + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# Inner repo identity. Per-call (-c user.X) so this never touches the +# user's ~/.gitconfig. +_AUTHOR_EMAIL = "rpgkit@local" +_AUTHOR_NAME = "rpgkit-snapshot" + + +def _author_args() -> list[str]: + return [ + "-c", f"user.email={_AUTHOR_EMAIL}", + "-c", f"user.name={_AUTHOR_NAME}", + # Disable system / user / xdg git config so an unusual global + # template doesn't leak (e.g. signing keys, hooks) into our + # private snapshot repo. + "-c", "init.defaultBranch=main", + ] + + +# Skip patterns — these scripts are read-only or long-running and +# shouldn't pollute the snapshot history. +_SKIP_NAMES: frozenset[str] = frozenset({ + "mcp_server.py", +}) + + +def _basename(relpath: str) -> str: + return relpath.rsplit("/", 1)[-1] + + +def should_skip_script(relpath: str) -> bool: + """True iff this script should NOT trigger an auto-commit.""" + base = _basename(relpath) + if base in _SKIP_NAMES: + return True + # Read-only state checkers — e.g. check_skeleton.py, check_code_gen.py + if base.startswith("check_") and base.endswith(".py"): + return True + # Pre-flight validators — e.g. feature_build_validation.py + if base.endswith("_validation.py"): + return True + return False + + +def categorise_script(relpath: str) -> str: + """Map a script relpath to a short commit-message category tag.""" + rel = relpath.replace("\\", "/") + if rel.startswith("rpg_encoder/"): + return "encoder" + if rel.startswith("rpg_edit/"): + return "rpg_edit" + base = _basename(rel) + if base == "update_graphs.py": + return "sync" + if base == "mcp_server.py": + # Defensive: today this is on the skip list so we never commit + # an mcp_server.py invocation, but if that ever changes, tag + # it correctly rather than defaulting to ``decoder``. + return "mcp" + return "decoder" + + +# --------------------------------------------------------------------------- +# Filesystem helpers +# --------------------------------------------------------------------------- + +def _rpgkit_dir(workspace: Path) -> Path: + return workspace / ".rpgkit" + + +def find_workspace_root(start: Optional[Path] = None) -> Optional[Path]: + """Walk up from ``start`` (default cwd) looking for a ``.rpgkit/`` dir. + + Returns the directory containing ``.rpgkit/``, or ``None`` if not found. + Used by ``rpgkit script`` to figure out which workspace's inner git + repo to snapshot into when the caller's cwd is a subdirectory. + """ + here = (start or Path.cwd()).resolve() + for cand in [here, *here.parents]: + if (cand / ".rpgkit").is_dir(): + return cand + return None + + +def has_inner_git(workspace: Path) -> bool: + return (_rpgkit_dir(workspace) / ".git").is_dir() + + +def _git_available() -> bool: + from shutil import which + return which("git") is not None + + +def _run_git(workspace: Path, *args: str, check: bool = False, timeout: int = 30) -> subprocess.CompletedProcess[str]: + """Run ``git -C .rpgkit ...`` capturing stdout/stderr. + + ``check=False`` by default — callers inspect ``returncode`` themselves so + we can silently swallow expected failures (lock, no-changes, etc.). + + The child environment forces ``LC_ALL=C`` so git's error messages + are in English regardless of the user's locale. We pattern-match + on those messages (see ``_LOCK_HINTS``) to decide whether to retry + on lock contention. + """ + import os as _os + env = {**_os.environ, "LC_ALL": "C", "LANG": "C"} + cmd = ["git", "-C", str(_rpgkit_dir(workspace))] + list(args) + return subprocess.run( + cmd, + check=check, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- + +def ensure_inner_git(workspace: Path, *, initial_msg: Optional[str] = None) -> bool: + """Create ``.rpgkit/.git`` if missing. Returns ``True`` when newly created. + + Idempotent: if the repo already exists, returns ``False`` and leaves it + untouched. + + When a fresh repo is created, an initial commit captures the current + state of ``.rpgkit/`` (config.toml, .source, empty data/, ...). + """ + rpgkit = _rpgkit_dir(workspace) + if not rpgkit.is_dir(): + return False # nothing to track + if (rpgkit / ".git").is_dir(): + return False + if not _git_available(): + return False + + try: + # Use 'main' as default branch to match the workspace default and + # avoid the noisy "hint: Using 'master'" message on fresh git. + _run_git(workspace, "init", "-q", "-b", "main", check=True) + except Exception: + return False + + # Initial commit — even if empty, it gives `git log` a starting point. + initial_msg = initial_msg or "[init] rpgkit workspace" + _commit_all(workspace, initial_msg, allow_empty=True) + return True + + +# --------------------------------------------------------------------------- +# Commit primitives +# --------------------------------------------------------------------------- + +def _has_staged_changes(workspace: Path) -> bool: + """True iff something is staged for commit.""" + r = _run_git(workspace, "diff", "--staged", "--quiet") + # exit 1 = differences exist, 0 = none, anything else = error (treat as no) + return r.returncode == 1 + + +_LOCK_HINTS = ("index.lock", "Another git process seems") + + +def _commit_all(workspace: Path, message: str, *, allow_empty: bool = False) -> bool: + """Stage everything and commit. Returns True iff a commit was created. + + Concurrent-safe: if the index lock is held by a parallel git process + (e.g. the post-commit hook firing ``rpgkit script update_graphs.py`` + in the background), we retry once after a short sleep, then give up + silently. The next successful commit will fold in any deferred + changes — no data is lost. + """ + for attempt in (1, 2): + try: + r_add = _run_git(workspace, "add", "-A") + if r_add.returncode != 0: + # Likely a lock; try again + if any(h in (r_add.stderr or "") for h in _LOCK_HINTS) and attempt == 1: + time.sleep(1.0) + continue + return False + + if not allow_empty and not _has_staged_changes(workspace): + return False # nothing to commit; not an error + + commit_args = ["commit", "-m", message, "--quiet"] + if allow_empty: + commit_args.insert(1, "--allow-empty") + r_c = _run_git(workspace, *_author_args(), *commit_args) + if r_c.returncode == 0: + return True + # Retry on lock + if any(h in (r_c.stderr or "") for h in _LOCK_HINTS) and attempt == 1: + time.sleep(1.0) + continue + return False + except Exception: + return False + return False + + +# --------------------------------------------------------------------------- +# Public entry: after a `rpgkit script ` call +# --------------------------------------------------------------------------- + +def _build_message(script_relpath: str, args: list[str], exit_code: int) -> str: + cat = categorise_script(script_relpath) + # ``shlex.quote`` keeps args with spaces / special chars unambiguous + # in the commit log: ``[decoder] X.py 'some path'`` rather than + # ``[decoder] X.py some path``. + quoted = " ".join(shlex.quote(a) for a in args) + args_part = (" " + quoted).rstrip() if quoted else "" + # Cap args length so a giant args string doesn't make commit messages unreadable. + if len(args_part) > 80: + args_part = args_part[:77] + "..." + suffix = "" + if exit_code != 0: + suffix = f" — FAILED (exit {exit_code})" + return f"[{cat}] {script_relpath}{args_part}{suffix}" + + +def auto_commit_after_script( + workspace: Path, + script_relpath: str, + args: list[str], + exit_code: int, +) -> None: + """Snapshot ``.rpgkit/`` after a ``rpgkit script`` call completes. + + No-ops (silently) when any of: + * ``.rpgkit/.git`` is missing + * the script matches a skip pattern + * git is unavailable + * the index is locked and the retry still fails + * nothing actually changed + """ + try: + if should_skip_script(script_relpath): + return + if not has_inner_git(workspace): + return + message = _build_message(script_relpath, args, exit_code) + _commit_all(workspace, message, allow_empty=False) + except Exception: + # Never let snapshot machinery break the calling CLI. + return + + +# --------------------------------------------------------------------------- +# `rpgkit version` helper +# --------------------------------------------------------------------------- + +def snapshot_count(workspace: Path) -> Optional[int]: + """Return number of commits in the inner repo, or None if absent.""" + if not has_inner_git(workspace): + return None + try: + r = _run_git(workspace, "rev-list", "--count", "HEAD") + if r.returncode == 0: + return int((r.stdout or "0").strip()) + except Exception: + pass + return None From 83cc59e1eef6a1b4cf8aa919a7a1c500652bd6f7 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Wed, 20 May 2026 10:20:02 +0800 Subject: [PATCH 14/31] feat(cli): auto-register rpg-tools in Copilot CLI global MCP config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub Copilot CLI (`copilot`) does NOT read workspace-local `.vscode/mcp.json`; it only reads `~/.copilot/mcp-config.json` (or accepts inline JSON via `--additional-mcp-config`). To make `copilot` find `rpg-tools` automatically in any rpgkit-initialised workspace, we now register the server globally on `rpgkit init --ai copilot` and `rpgkit update --ai copilot`. This is safe because rpgkit-mcp is cwd-aware (walks up for rpg.json) and stateless across workspaces — one global registration serves every workspace the user cd-s into. In workspaces without rpg.json the server starts in degraded mode and tool calls return a rpg_unavailable hint instructing the user to run /rpgkit.encode. Safety rules baked into _register_copilot_cli_global_mcp(): - No-op when in-sync: if the file already contains exactly our desired entry, we don't touch it (no mtime bump, no .bak). - Refuse to wipe a malformed config: file exists but isn't valid JSON -> abort with a clear error; user fixes it or passes --no-copilot-cli-mcp. Without this a stray comma would let us silently drop every non-rpg-tools server. - Atomic write: serialise to mcp-config.json.tmp then os.replace() into place, so Ctrl-C mid-write can't leave the file half-written. - Respect user-customised entries: existing rpg-tools whose `command` is not `rpgkit-mcp` is left alone (user has pointed it elsewhere, e.g. a dev checkout). - One-shot .bak: only created on the first modification we actually perform; never on no-op runs, never overwritten. New flag: --no-copilot-cli-mcp on both `init` and `update` to opt out. Wired into the StepTracker plan so the tree output shows the step. Verified against five scenarios (fresh, idempotent no-op, preserve other-servers + update outdated entry, refuse malformed JSON, respect user-customised command) — all PASS, no stray .tmp files. --- RPG-Kit/src/rpgkit_cli/__init__.py | 219 +++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 7456f1e..80e243b 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -1513,6 +1513,175 @@ def _generate_mcp_config( console.print(f"[yellow]Warning: Could not generate MCP config: {e}[/yellow]") +# --------------------------------------------------------------------------- +# Copilot CLI: global MCP registration +# --------------------------------------------------------------------------- + +_COPILOT_CLI_MCP_CONFIG = Path.home() / ".copilot" / "mcp-config.json" + + +def _register_copilot_cli_global_mcp(tracker=None) -> None: + """Register ``rpg-tools`` in ``~/.copilot/mcp-config.json`` (global). + + The GitHub Copilot CLI (``copilot``) — unlike the VS Code Copilot + extension — does NOT read workspace-local ``.vscode/mcp.json``. It + only reads the global ``~/.copilot/mcp-config.json`` (or accepts + inline JSON via ``--additional-mcp-config``). + + To make ``copilot`` find ``rpg-tools`` automatically in any + rpgkit-initialised workspace, we register the server globally on + first ``rpgkit init --ai copilot`` (or ``rpgkit update``). + + This is safe because ``rpgkit-mcp`` is cwd-aware (it walks up to + find ``rpg.json``) and stateless across workspaces — one global + registration serves every workspace the user ``cd``-s into. In + workspaces without ``rpg.json`` the server starts in degraded mode + and tool calls return a ``rpg_unavailable`` hint instructing the + user to run ``/rpgkit.encode``. + + Safety rules (see audit decisions D-globalmcp-1..4): + - **No-op when in-sync.** If the file already contains exactly + the entry we'd write, we don't touch it at all (no mtime bump, + no .bak). This makes ``rpgkit update`` cheap to run repeatedly. + - **Refuse to wipe a malformed config.** If the file exists but + isn't valid JSON we abort with a clear error instead of + overwriting; the user is expected to fix it (or run with + ``--no-copilot-cli-mcp``). Without this guard a stray comma + in the user's config would have us silently drop every + non-rpg-tools server. + - **Atomic write.** We serialise to ``mcp-config.json.tmp`` + first and then ``os.replace()`` into place, so a Ctrl-C or + crash mid-write can't leave the file half-written. + - **Respect user-customised entries.** If an existing + ``rpg-tools`` entry uses a different ``command`` (the user has + intentionally pointed it elsewhere, e.g. to a dev checkout) we + leave it alone and ask them to use ``--no-copilot-cli-mcp``. + - **One-shot .bak.** Only created on the first modification we + actually perform — never on no-op runs, never overwritten. + """ + config_path = _COPILOT_CLI_MCP_CONFIG + bak_path = config_path.with_suffix(".json.bak") + tmp_path = config_path.with_suffix(".json.tmp") + desired = { + "type": "stdio", + "command": "rpgkit-mcp", + "args": [], + } + + def _report_skip(detail: str) -> None: + if tracker: + tracker.skip("copilot-cli-mcp", detail) + + def _report_error(detail: str) -> None: + if tracker: + tracker.error("copilot-cli-mcp", detail) + else: + console.print( + f"[yellow]Warning: could not register rpg-tools in " + f"{config_path}: {detail}[/yellow]" + ) + + def _report_done(detail: str) -> None: + if tracker: + tracker.complete("copilot-cli-mcp", detail) + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + + # ----- Parse existing file (strictly, so we can refuse to + # clobber a malformed user config). An empty/missing file is + # fine — we treat that as "start fresh". + if config_path.exists(): + raw = config_path.read_text(encoding="utf-8") + if raw.strip() == "": + existing: Dict[str, Any] = {} + else: + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + _report_error( + f"{config_path} is not valid JSON ({exc.msg} " + f"at line {exc.lineno} col {exc.colno}); refusing to " + f"overwrite. Fix the file or re-run with " + f"--no-copilot-cli-mcp." + ) + return + if not isinstance(parsed, dict): + _report_error( + f"{config_path} top-level is not a JSON object; " + f"refusing to overwrite. Re-run with " + f"--no-copilot-cli-mcp." + ) + return + existing = parsed + else: + existing = {} + + servers = existing.get("mcpServers") + if servers is None: + existing["mcpServers"] = {} + servers = existing["mcpServers"] + elif not isinstance(servers, dict): + _report_error( + f"{config_path}: `mcpServers` is not a JSON object; " + f"refusing to overwrite. Re-run with --no-copilot-cli-mcp." + ) + return + + current = servers.get("rpg-tools") + # No-op fast path: file already contains exactly what we'd write. + if current == desired: + _report_skip(f"already up-to-date at {config_path}") + return + + # Respect a user-customised entry — only touch entries that + # either don't exist or already point at our `rpgkit-mcp` + # console script (the latter happens on a version bump where + # we'd want to e.g. add new default args). + if ( + isinstance(current, dict) + and current.get("command") + and current.get("command") != "rpgkit-mcp" + ): + _report_skip( + f"existing entry uses custom command " + f"{current.get('command')!r}; leaving alone " + f"(use --no-copilot-cli-mcp to silence)" + ) + return + + # We're going to write — back up the original (one-shot). + if config_path.exists() and not bak_path.exists(): + try: + shutil.copy2(config_path, bak_path) + except OSError: + pass # backup is best-effort + + servers["rpg-tools"] = desired + + # Atomic write: serialise to .tmp then rename. ``os.replace`` + # is atomic on POSIX and Windows. + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(existing, f, indent=2) + f.write("\n") + os.replace(tmp_path, config_path) + except Exception: + # Clean up a stray .tmp on failure so the next run isn't + # confused by a leftover. + try: + if tmp_path.exists(): + tmp_path.unlink() + except OSError: + pass + raise + + action = "updated" if current is not None else "registered" + _report_done(f"{action} at {config_path}") + except Exception as exc: + _report_error(f"failed: {exc}") + + # --------------------------------------------------------------------------- # Optional initial encode # --------------------------------------------------------------------------- @@ -3488,6 +3657,18 @@ def init( "--no-mcp", help="Skip MCP server registration (rpg-tools won't be exposed to the AI agent)", ), + no_copilot_cli_mcp: bool = typer.Option( + False, + "--no-copilot-cli-mcp", + help=( + "When --ai copilot is selected, skip also registering " + "rpg-tools globally in ~/.copilot/mcp-config.json. The " + "Copilot CLI does not read workspace .vscode/mcp.json, so " + "this global registration is what makes `copilot` find " + "rpg-tools. Pass this flag if you manage your Copilot CLI " + "MCP config by hand." + ), + ), legacy_download: bool = typer.Option( False, "--legacy-download", @@ -3692,6 +3873,7 @@ def init( ("chmod", "Ensure scripts executable"), ("gitignore", "Configure .gitignore"), ("mcp", "Configure MCP server"), + ("copilot-cli-mcp", "Register rpg-tools in ~/.copilot/mcp-config.json"), ("legacy-cleanup", "Remove obsolete persistent rules"), ("cleanup", "Cleanup"), ("git", "Initialize git repository"), @@ -3757,6 +3939,19 @@ def init( else: _generate_mcp_config(project_path, selected_ai, tracker=tracker) + # Global registration for Copilot CLI (which doesn't read + # workspace .vscode/mcp.json). Skipped for non-copilot AIs, + # when --no-mcp is set, or when the user opts out explicitly. + if no_mcp: + pass + elif selected_ai != "copilot": + tracker.skip("copilot-cli-mcp", f"ai={selected_ai}") + elif no_copilot_cli_mcp: + tracker.skip("copilot-cli-mcp", "--no-copilot-cli-mcp flag") + else: + tracker.start("copilot-cli-mcp") + _register_copilot_cli_global_mcp(tracker=tracker) + # Migrate workspaces created before C4: drop the auto-loaded # rpgkit-codegen.* persistent-instruction files. tracker.start("legacy-cleanup") @@ -4048,6 +4243,18 @@ def update( "--no-mcp", help="Skip MCP server registration (rpg-tools won't be exposed to the AI agent)", ), + no_copilot_cli_mcp: bool = typer.Option( + False, + "--no-copilot-cli-mcp", + help=( + "When --ai copilot is selected, skip also registering " + "rpg-tools globally in ~/.copilot/mcp-config.json. The " + "Copilot CLI does not read workspace .vscode/mcp.json, so " + "this global registration is what makes `copilot` find " + "rpg-tools. Pass this flag if you manage your Copilot CLI " + "MCP config by hand." + ), + ), legacy_download: bool = typer.Option( False, "--legacy-download", @@ -4217,6 +4424,7 @@ def update( ("chmod", "Ensure scripts executable"), ("gitignore", "Configure .gitignore"), ("mcp", "Configure MCP server"), + ("copilot-cli-mcp", "Register rpg-tools in ~/.copilot/mcp-config.json"), ("legacy-cleanup", "Remove obsolete persistent rules"), ("hooks", "Install auto-update hooks"), ("cleanup", "Cleanup"), @@ -4287,6 +4495,17 @@ def update( else: _generate_mcp_config(project_path, selected_ai, tracker=tracker) + # Global registration for Copilot CLI (see init for rationale). + if no_mcp: + pass + elif selected_ai != "copilot": + tracker.skip("copilot-cli-mcp", f"ai={selected_ai}") + elif no_copilot_cli_mcp: + tracker.skip("copilot-cli-mcp", "--no-copilot-cli-mcp flag") + else: + tracker.start("copilot-cli-mcp") + _register_copilot_cli_global_mcp(tracker=tracker) + # Migrate workspaces created before C4: drop the auto-loaded # rpgkit-codegen.* persistent-instruction files. tracker.start("legacy-cleanup") From a0e5b78a6c4365116833fbbb698237ab08af2278 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Wed, 20 May 2026 15:52:04 +0800 Subject: [PATCH 15/31] fix(templates): drop deprecated `mode: agent` from 3 prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code Copilot's custom-agents schema (the rename of the old chatmode format) no longer recognises the `mode:` frontmatter field. Three of our prompt templates still carried `mode: agent` from the chatmode era, producing this on load: Custom Agents — The following agents have warnings: • rpgkit.code_gen.agent.md: unknown field ignored: mode • rpgkit.design_interfaces.agent.md: unknown field ignored: mode • rpgkit.plan_tasks.agent.md: unknown field ignored: mode Fix: replace `mode: agent` with the canonical `name:` field so the three files match the frontmatter convention already used by the other ten prompts (`name:` + `description:`). Files: code_gen.md, design_interfaces.md, plan_tasks.md. No behavioural change — `mode:` was already being silently ignored; this just clears the warnings so the diagnostics view stays clean. Ref: https://code.visualstudio.com/docs/copilot/customization/custom-agents#_are-custom-agents-different-from-chat-modes --- RPG-Kit/templates/commands/code_gen.md | 2 +- RPG-Kit/templates/commands/design_interfaces.md | 2 +- RPG-Kit/templates/commands/plan_tasks.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RPG-Kit/templates/commands/code_gen.md b/RPG-Kit/templates/commands/code_gen.md index adfe9a9..5dcaf2e 100644 --- a/RPG-Kit/templates/commands/code_gen.md +++ b/RPG-Kit/templates/commands/code_gen.md @@ -1,5 +1,5 @@ --- -mode: agent +name: rpgkit.code_gen description: Implement code using TDD workflow with iterative test-code-fix cycles --- diff --git a/RPG-Kit/templates/commands/design_interfaces.md b/RPG-Kit/templates/commands/design_interfaces.md index 8526869..5205086 100644 --- a/RPG-Kit/templates/commands/design_interfaces.md +++ b/RPG-Kit/templates/commands/design_interfaces.md @@ -1,5 +1,5 @@ --- -mode: agent +name: rpgkit.design_interfaces description: Design interfaces (functions/classes) for repository files --- diff --git a/RPG-Kit/templates/commands/plan_tasks.md b/RPG-Kit/templates/commands/plan_tasks.md index 467f7bc..70e1a4f 100644 --- a/RPG-Kit/templates/commands/plan_tasks.md +++ b/RPG-Kit/templates/commands/plan_tasks.md @@ -1,5 +1,5 @@ --- -mode: agent +name: rpgkit.plan_tasks description: Plan implementation tasks from interface definitions --- From 816398d97d2c1b535af377ee9682b134d1054592 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Thu, 21 May 2026 18:40:19 +0800 Subject: [PATCH 16/31] feat(rpgkit): move runtime state out of workspace (scripts, docs, tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-workspace data, logs and the inner-git snapshot repo now live at ~/.rpgkit/workspaces// (hash = sha256(realpath(workspace))[:12]) so the user's repository stays clean. Only the workspace marker (.rpgkit/config.toml) and small user-facing reports (.rpgkit/reports/) stay in the workspace; rpg.html lives there too so it can be browsed next to the code. Scripts: - common/paths.py: derive DATA_DIR/LOGS_DIR/RPG_FILE/RPG_HTML_FILE from the new home-side helpers. RPG_HTML_FILE points to REPORTS_DIR. - feature_spec_to_json.py defaults to FEATURE_SPEC_FILE; the encoder, update_graphs.py and rpg_edit/* read and write through the new common.paths constants. - New common/rpg_io.py with atomic_write_rpg and safe_load_rpg. When rpg.json is truncated or invalid, scan the inner-git snapshot repo for the most recent valid copy, rewrite atomically and continue. - New rpg_edit/save_plan.py so prompts can pipe EditPlan JSON into the home-side data store via the CLI rather than the Write tool. - init_codebase.py: drop dead 'scripts = get_scripts_dir()' assignments and the now-unused import. Prompt templates & docs: - README.md and docs/* describe the new layout and point users at 'rpgkit view-graph' to open rpg.html without computing the hash. - templates/commands/*.md drop shell '> .rpgkit/logs/X.log 2>&1' redirections (scripts log internally) and remove every reference to '~/.rpgkit/workspaces//' from agent-facing prompts. The agent only reads stdout, and '' was often misread as a literal placeholder. Tests: - New tests/test_storage.py: workspace id, meta read/write, path helpers, WorkspaceMetaMismatch guard. - New tests/test_rpg_io.py: atomic-write, unicode roundtrip, inner-git restore on corruption. - test_e2e.py: assertion no longer hard-codes '.rpgkit/data/'. Comment-style cleanup: - Drop internal codename references (Plan B, Plan 03, plan A2/A3, plan B3/B4, plan E2/E3, plans/*.md, plan §2.5). - Drop emphasis adverbs (deliberately, the single most, carve-out, Defensive:), version narration (pre-0.1.3, Pre-v4) and caps emphasis (MUST NOT, do NOT) from docstrings and inline comments. --- RPG-Kit/README.md | 12 +- RPG-Kit/docs/cli-reference.md | 31 +- RPG-Kit/docs/commands.md | 2 + RPG-Kit/docs/configuration.md | 2 + RPG-Kit/docs/project-structure.md | 46 ++- RPG-Kit/scripts/__init__.py | 1 - RPG-Kit/scripts/code_gen/__init__.py | 2 +- RPG-Kit/scripts/code_gen/global_review.py | 4 +- RPG-Kit/scripts/code_gen/rpg_updater.py | 2 +- RPG-Kit/scripts/common/execution_state.py | 9 +- RPG-Kit/scripts/common/llm_client.py | 49 +-- RPG-Kit/scripts/common/paths.py | 141 ++++++-- RPG-Kit/scripts/common/project_types.py | 2 - RPG-Kit/scripts/common/rpg_io.py | 263 ++++++++++++++ RPG-Kit/scripts/feature_build_validation.py | 4 +- RPG-Kit/scripts/feature_spec_to_json.py | 15 +- RPG-Kit/scripts/init_codebase.py | 7 +- RPG-Kit/scripts/mcp_server.py | 22 +- RPG-Kit/scripts/plan_tasks.py | 6 +- RPG-Kit/scripts/rpg/graph_query.py | 19 +- RPG-Kit/scripts/rpg/models.py | 6 +- RPG-Kit/scripts/rpg_edit/apply.py | 7 +- RPG-Kit/scripts/rpg_edit/code.py | 7 +- RPG-Kit/scripts/rpg_edit/impact.py | 12 +- RPG-Kit/scripts/rpg_edit/locate.py | 1 - RPG-Kit/scripts/rpg_edit/review.py | 11 +- RPG-Kit/scripts/rpg_edit/save_plan.py | 56 +++ RPG-Kit/scripts/rpg_edit/validate.py | 1 - RPG-Kit/scripts/rpg_encoder/run_encode.py | 10 +- .../scripts/rpg_encoder/version_control.py | 9 +- RPG-Kit/scripts/rpg_encoder/workflow.py | 8 +- RPG-Kit/scripts/run_batch.py | 4 +- RPG-Kit/scripts/update_graphs.py | 18 +- RPG-Kit/templates/commands/build_data_flow.md | 9 +- RPG-Kit/templates/commands/build_skeleton.md | 34 +- .../templates/commands/design_base_classes.md | 9 +- .../templates/commands/design_interfaces.md | 9 +- RPG-Kit/templates/commands/encode.md | 7 +- RPG-Kit/templates/commands/feature_build.md | 21 +- RPG-Kit/templates/commands/feature_edit.md | 9 +- .../templates/commands/feature_refactor.md | 9 +- RPG-Kit/templates/commands/plan_tasks.md | 9 +- RPG-Kit/templates/commands/rpg_edit.md | 43 +-- RPG-Kit/templates/commands/update_rpg.md | 30 +- RPG-Kit/tests/test_dep_graph_incremental.py | 2 +- RPG-Kit/tests/test_e2e.py | 10 +- RPG-Kit/tests/test_rpg_io.py | 209 +++++++++++ RPG-Kit/tests/test_step3_polish.py | 3 +- RPG-Kit/tests/test_storage.py | 324 ++++++++++++++++++ .../tests/test_workspace_unified_layout.py | 2 +- 50 files changed, 1268 insertions(+), 260 deletions(-) create mode 100644 RPG-Kit/scripts/common/rpg_io.py create mode 100644 RPG-Kit/scripts/rpg_edit/save_plan.py create mode 100644 RPG-Kit/tests/test_rpg_io.py create mode 100644 RPG-Kit/tests/test_storage.py diff --git a/RPG-Kit/README.md b/RPG-Kit/README.md index 3cb05f4..a8e9cb3 100644 --- a/RPG-Kit/README.md +++ b/RPG-Kit/README.md @@ -79,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit in action -Below is part of the graph visualization generated for this repository. Run `/rpgkit.encode` and open `.rpgkit/data/rpg.html` to explore the full interactive graph. +Below is part of the graph visualization generated for this repository. Run `/rpgkit.encode` then `rpgkit view-graph` to open the full interactive graph in your browser (the underlying file is `/.rpgkit/reports/rpg.html`). ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -126,7 +126,7 @@ cd my-project rpgkit update --legacy-download ``` -`rpgkit update` records the channel you chose in `.rpgkit/.source` so subsequent runs default to the same source. Your edits to `.rpgkit/config.toml` (see [`docs/configuration.md`](docs/configuration.md)) are preserved across updates. +`rpgkit update` records the channel you chose in `~/.rpgkit/workspaces//.meta.toml` so subsequent runs default to the same source. Your edits to `/.rpgkit/config.toml` (see [`docs/configuration.md`](docs/configuration.md)) are preserved across updates. ## Quick Start: New Repository @@ -170,7 +170,7 @@ Use this path when you want RPG-Kit to turn requirements into a new codebase. [Optional] /rpgkit.rpg_edit ``` -RPG-Kit progressively creates `.rpgkit/data/rpg.json` and uses it to keep requirements, planning artifacts, generated code, and dependency information aligned. +RPG-Kit progressively builds an `rpg.json` (under `~/.rpgkit/workspaces//data/`) and uses it to keep requirements, planning artifacts, generated code, and dependency information aligned. Runtime state lives outside your workspace so your git history stays clean; only `/.rpgkit/{config.toml,reports/}` belong in the repo. ## Quick Start: Existing Repository @@ -204,7 +204,7 @@ Use this path when you already have a repository and want an AI agent to underst /rpgkit.rpg_edit # graph-aware code edit ``` -4. After commits, RPG-Kit hooks keep `.rpgkit/data/rpg.json`, `.rpgkit/data/dep_graph.json`, and `.rpgkit/data/rpg.html` aligned with code changes. If the hook fails or is skipped, run `/rpgkit.update_rpg`. +4. After commits, RPG-Kit hooks keep the workspace's `rpg.json`, `dep_graph.json` (under `~/.rpgkit/workspaces//data/`) and the user-facing `rpg.html` (under `/.rpgkit/reports/`) aligned with code changes. If the hook fails or is skipped, run `/rpgkit.update_rpg`. ## What happens after `rpgkit init` @@ -253,9 +253,9 @@ See [docs/project-structure.md](docs/project-structure.md) for the full layout a **AI assistant CLI not found:** run `rpgkit check`, install and authenticate the selected assistant CLI, then rerun `rpgkit init` or `rpgkit update`. -**MCP tools report `rpg_unavailable`:** run `/rpgkit.encode` to create `.rpgkit/data/rpg.json`. +**MCP tools report `rpg_unavailable`:** run `/rpgkit.encode` to create the workspace's `rpg.json` (under `~/.rpgkit/workspaces//data/`). -**Incremental update failed:** inspect `.rpgkit/logs/update_rpg.log`, then run `/rpgkit.update_rpg`. +**Incremental update failed:** inspect `~/.rpgkit/workspaces//logs/update_rpg.log`, then run `/rpgkit.update_rpg`. **Template download fails due to rate limits or private repo access:** pass `--github-token $GITHUB_TOKEN` or set `GH_TOKEN` / `GITHUB_TOKEN`. diff --git a/RPG-Kit/docs/cli-reference.md b/RPG-Kit/docs/cli-reference.md index b69e426..970ebce 100644 --- a/RPG-Kit/docs/cli-reference.md +++ b/RPG-Kit/docs/cli-reference.md @@ -72,11 +72,38 @@ rpgkit update --github-token $GITHUB_TOKEN | `--github-token ` | GitHub token for private repos or higher rate limits | | `--pre` | Download the latest pre-release template | | `--legacy-download` | Bypass the packaged assets and pull from the latest GitHub release zip (implied by `--pre`) | -| `--pull` | Self-upgrade the CLI (auto-detects uv / pipx / pip) before syncing the workspace | +| `--pull` | Force a self-upgrade of the CLI (auto-detects uv / pipx / pip) before syncing the workspace. Conflicts with `--no-pull`. | +| `--no-pull` | Skip the self-upgrade and only sync workspace files. Conflicts with `--pull`. | | `--no-mcp` | Skip MCP server configuration | | `--skip-tls` | Skip SSL/TLS verification | | `--debug` | Show verbose diagnostic output | +### Auto-upgrade behaviour + +Since the global-install layout, `rpgkit update` performs a **best-effort silent self-upgrade by default** when the install source is safe to refresh (git+URL or PyPI). After upgrading the CLI it re-executes itself once to continue the workspace sync with the new code. Editable installs, local-file installs, and unknown sources are skipped silently. + +- Pass `--pull` to force an upgrade attempt regardless of the detected source. +- Pass `--no-pull` to skip the upgrade entirely (useful for offline or pinned environments). +- `--pull` and `--no-pull` are mutually exclusive; passing both exits with status 2. +- A loop guard environment variable (`RPGKIT_UPGRADE_DONE`) is set across the re-exec to guarantee at most one upgrade attempt per invocation. + +## `rpgkit view-graph` + +Open the most recent `rpg.html` visualisation for the current workspace in your default browser. Walks up from the current directory to find the workspace root, then locates `rpg.html` under `/.rpgkit/reports/` (preferred, may be checked into git) or `~/.rpgkit/workspaces//data/` as a fallback for encoder output not yet promoted to reports. + +```bash +rpgkit view-graph +rpgkit view-graph --no-open # print the file URI but do not launch a browser +``` + +### Exit codes + +| Code | Meaning | +| ---- | ------- | +| `0` | Found `rpg.html` and (unless `--no-open`) opened it | +| `1` | Not inside an RPG-Kit workspace | +| `2` | Workspace found but no `rpg.html` has been generated yet (run `/rpgkit.encode` or the forward pipeline first) | + ### Provisioning sources Since `0.1.3`, `rpgkit init` and `rpgkit update` provision from two channels: @@ -86,7 +113,7 @@ Since `0.1.3`, `rpgkit init` and `rpgkit update` provision from two channels: | Packaged assets (bundle) | Default. Pulled from `rpgkit_cli/core_pack/` inside the installed wheel | No | | GitHub release zip (legacy) | `--legacy-download`, `--pre`, or `--script ps`, or when the bundle is unavailable (e.g. editable installs) | Yes | -`rpgkit init` writes the choice to `.rpgkit/.source` (`bundle` or `legacy`) so subsequent `rpgkit update` invocations default to the same channel. Override with the flag of your choice at any time. +`rpgkit init` records the chosen channel (`bundle` or `legacy`) in `~/.rpgkit/workspaces//.meta.toml` so subsequent `rpgkit update` invocations default to the same channel. Override with the flag of your choice at any time. Verify that required tools are installed. diff --git a/RPG-Kit/docs/commands.md b/RPG-Kit/docs/commands.md index 1176dde..f06d5f1 100644 --- a/RPG-Kit/docs/commands.md +++ b/RPG-Kit/docs/commands.md @@ -6,6 +6,8 @@ RPG-Kit provides 13 slash commands that work in three paths: - **Reverse encoder:** Existing code → RPG - **Surgical edit:** Natural-language changes applied to code, RPG, and dependency graph together +> **Note on data paths.** Throughout this document, paths shown as `.rpgkit/data/...` and `.rpgkit/logs/...` are stable logical names. The actual files live **outside the workspace** under `~/.rpgkit/workspaces//{data,logs}/` so that runtime artefacts never enter the user's git repository. Reports (`rpg.html`, review HTML, etc.) stay in the workspace at `/.rpgkit/reports/` because they are small user-facing artefacts users may want to commit. Use `rpgkit view-graph` to open `rpg.html` without having to look up the hash. See [project-structure.md](project-structure.md) for the full layout. + ## Command Overview ### Phase 1: Feature Specification diff --git a/RPG-Kit/docs/configuration.md b/RPG-Kit/docs/configuration.md index 4674fba..11af07d 100644 --- a/RPG-Kit/docs/configuration.md +++ b/RPG-Kit/docs/configuration.md @@ -2,6 +2,8 @@ This document covers RPG-Kit configuration that is useful after installation: AI assistant setup, MCP registration, auto-approval, hooks, and initial encoding. +> **Data paths.** References below such as `.rpgkit/data/rpg.json` and `.rpgkit/logs/...` are logical names. Runtime files actually live under `~/.rpgkit/workspaces//{data,logs}/` so they stay outside your git repo. Reports stay in the workspace at `/.rpgkit/reports/`. The MCP server, hooks, and pipeline scripts all resolve the home-dir location automatically from the workspace root. Use `rpgkit view-graph` to open the visualisation without computing the hash; see [project-structure.md](project-structure.md) for the full layout. + ## AI Assistant CLI Requirements RPG-Kit slash commands are executed by an AI coding agent. Before running `rpgkit init`, install and authenticate at least one supported AI assistant CLI. diff --git a/RPG-Kit/docs/project-structure.md b/RPG-Kit/docs/project-structure.md index 84daa31..3f66618 100644 --- a/RPG-Kit/docs/project-structure.md +++ b/RPG-Kit/docs/project-structure.md @@ -4,9 +4,9 @@ RPG-Kit installs alongside your project code: the directory you run `rpgkit init` in, also called the workspace root, **is** the project repository root. There is no separate `repo/` subdirectory. This means: -- `rpgkit init my-project` creates `my-project/` containing both your source code (`src/`, `tests/`, `docs/`) and RPG-Kit's runtime files (`.rpgkit/`, `.claude/`, `.github/`, `.vscode/`, depending on the selected agent). +- `rpgkit init my-project` creates `my-project/` containing both your source code (`src/`, `tests/`, `docs/`) and RPG-Kit's in-workspace configuration files (`.rpgkit/config.toml`, `.claude/`, `.github/`, `.vscode/`, depending on the selected agent). - `rpgkit init --here` inside an existing git repository adds RPG-Kit on top of the existing code without moving the repository. -- A single `.git` repository tracks user-owned code and any RPG-Kit files the user chooses to commit. Runtime data under `.rpgkit/data/` is gitignored by default. +- A single `.git` repository tracks user-owned code and any RPG-Kit files the user chooses to commit. **Runtime data, logs, reports, and the inner-git snapshot repo all live outside the workspace** under `~/.rpgkit/workspaces//`, so nothing generated by RPG-Kit pollutes your repo or accidentally gets committed. ## After `rpgkit init` @@ -41,12 +41,26 @@ my-project/ │ └── tasks.json # Optional workspace tasks └── .rpgkit/ ├── config.toml # Workspace AI / config (committed). See docs/configuration.md - ├── .source # Provisioning channel marker: "bundle" or "legacy" - ├── data/ # Runtime artifacts, populated by commands - ├── logs/ # Per-stage logs - └── reports/ # Review and diagnostic reports when generated + └── .source # Provisioning channel marker: "bundle" or "legacy" ``` +### Out-of-workspace runtime store + +Starting from the global-install layout, all runtime state lives under your home directory, keyed by a stable hash of the workspace's absolute path: + +```text +~/.rpgkit/workspaces// +├── .git/ # Inner-git snapshot repo (per-stage auto-commits) +├── .gitignore # Excludes logs/copilot/ only — other logs are tracked for debug +├── .meta.toml # Back-pointer to the workspace path + metadata +├── data/ # Runtime artifacts (rpg.json, dep_graph.json, ...) +└── logs/ # Per-stage logs (tracked by inner-git; LLM session traces under logs/copilot/ are excluded) +``` + +Reports (`rpg.html`, review HTML, …) stay **inside** the workspace at `/.rpgkit/reports/` because they are small, user-facing artefacts that benefit from sitting next to the code (and may be committed). + +The hash is computed as `sha256(os.path.realpath(workspace_path))[:12]`, so moving or renaming the workspace yields a different home directory. To open the RPG visualisation without remembering the hash, run [`rpgkit view-graph`](cli-reference.md) from anywhere inside the workspace. + > Pipeline scripts (formerly materialised into `.rpgkit/scripts/`) now live inside the installed `rpgkit-cli` wheel under `rpgkit_cli/core_pack/scripts/` and are invoked via the global [`rpgkit script `](cli-reference.md) command. They are no longer copied into each workspace, so `rpgkit init` produces a much smaller footprint and a single source of truth per CLI install. The agent configuration directory varies by the selected AI assistant and release package. For the verified CLI path, `--ai claude` installs `.claude/commands/`, while `--ai copilot` installs `.github/agents/`, `.github/prompts/`, and `.vscode/mcp.json`. @@ -55,7 +69,7 @@ Command definitions are installed into the AI-agent-specific folder. Normal user ## Generated Data Files -As you run `/rpgkit.*` commands, `.rpgkit/data/` is progressively populated: +As you run `/rpgkit.*` commands, `~/.rpgkit/workspaces//data/` is progressively populated (paths below are shown relative to that directory): | Generated file | Command | Description | | -------------- | ------- | ----------- | @@ -108,11 +122,17 @@ Typical producers and updaters: ## Runtime Logs and Reports -Runtime logs are written under `.rpgkit/logs/`, for example: +Runtime logs are written under `~/.rpgkit/workspaces//logs/`, for example: + +- `~/.rpgkit/workspaces//logs/encode.log` +- `~/.rpgkit/workspaces//logs/update_rpg.log` +- `~/.rpgkit/workspaces//logs/feature_build.log` +- `~/.rpgkit/workspaces//logs/build_data_flow.log` -- `.rpgkit/logs/encode.log` -- `.rpgkit/logs/update_rpg.log` -- `.rpgkit/logs/feature_build.log` -- `.rpgkit/logs/build_data_flow.log` +Execution traces are written under `~/.rpgkit/workspaces//data/trajectory/`. Review or diagnostic artifacts may be written under `/.rpgkit/reports/` when a command generates them. -Execution traces are written under `.rpgkit/data/trajectory/`. Review or diagnostic artifacts may be written under `.rpgkit/reports/` when a command generates them. +To discover the hash for the current workspace, run any rpgkit command with `--verbose`, or use Python: + +```bash +python3 -c 'import hashlib, os, sys; print(hashlib.sha256(os.path.realpath(sys.argv[1]).encode()).hexdigest()[:12])' . +``` diff --git a/RPG-Kit/scripts/__init__.py b/RPG-Kit/scripts/__init__.py index 32626eb..7152d72 100644 --- a/RPG-Kit/scripts/__init__.py +++ b/RPG-Kit/scripts/__init__.py @@ -14,4 +14,3 @@ # as a Python module. Callers always copy scripts into the user's # workspace and invoke them with ``python /.rpgkit/scripts/.py``. # -# Plan: ``plans/01-package-bundle-and-ai-config.md`` diff --git a/RPG-Kit/scripts/code_gen/__init__.py b/RPG-Kit/scripts/code_gen/__init__.py index 527eb57..3e22e4e 100644 --- a/RPG-Kit/scripts/code_gen/__init__.py +++ b/RPG-Kit/scripts/code_gen/__init__.py @@ -12,7 +12,7 @@ * :mod:`scripts.code_gen.static_checks` — lightweight pre-LLM checks * :mod:`scripts.code_gen.subtree_review` — LLM review of completed subtrees -The package deliberately exposes **no** re-exports. Callers import from +The package exposes no re-exports. Callers import from the specific submodule (``from code_gen.prompts import ...``) to keep dependency edges explicit and to avoid lying about which functions are really part of a stable public API. diff --git a/RPG-Kit/scripts/code_gen/global_review.py b/RPG-Kit/scripts/code_gen/global_review.py index 2939849..e44a99f 100644 --- a/RPG-Kit/scripts/code_gen/global_review.py +++ b/RPG-Kit/scripts/code_gen/global_review.py @@ -1056,7 +1056,7 @@ class _HeartbeatLogger: Designed to wrap a single long-running blocking call (typically ``dispatch_sub_agent`` inside a global_review iteration). Exits cleanly via context manager — the daemon thread stops as soon as - ``__exit__`` runs, even when the wrapped call raises (plan E2). + ``__exit__`` runs, even when the wrapped call raises. """ def __init__(self, label: str, interval_s: int = 60) -> None: @@ -1157,7 +1157,7 @@ def global_review( # 3. Dispatch sub-agent (with retries for transient failures). # Wrap with a heartbeat so the operator sees the iteration is # still alive even if the sub-agent runs for many minutes - # without producing output (plan E2). + # without producing output. with _HeartbeatLogger( label=f"global_review[{iteration}/{max_iterations}]", interval_s=60, diff --git a/RPG-Kit/scripts/code_gen/rpg_updater.py b/RPG-Kit/scripts/code_gen/rpg_updater.py index e4ff69e..e4e03b1 100644 --- a/RPG-Kit/scripts/code_gen/rpg_updater.py +++ b/RPG-Kit/scripts/code_gen/rpg_updater.py @@ -726,7 +726,7 @@ def run_rpg_update( # filesystem path. The path is only used to populate ``source_file`` in # edge metadata, which feeds into edge ``description`` text injected into # LLM prompts. Absolute paths leak host-specific prefixes - # (e.g. /home/.../RPG-Kit-backup/...) and mislead agents (plan A4). + # (e.g. /home/.../RPG-Kit-backup/...) and mislead agents. analyzer.analyze_file(Path(batch.file_path), code) analyzed_deps = analyzer.get_all_edges() diff --git a/RPG-Kit/scripts/common/execution_state.py b/RPG-Kit/scripts/common/execution_state.py index 1e3e5b3..85154e4 100644 --- a/RPG-Kit/scripts/common/execution_state.py +++ b/RPG-Kit/scripts/common/execution_state.py @@ -392,7 +392,7 @@ def _count_total_tasks_from_tasks_json(state_path: Path = STATE_FILE) -> int: Returns 0 if tasks.json doesn't exist or cannot be parsed. Used to backfill ``CodeGenState.total_tasks`` since nothing else writes that field after - ``plan_tasks`` runs (see plan A2). + ``plan_tasks`` runs. The tasks.json path is derived from ``state_path`` (assumed to live in the same ``.rpgkit/data/`` directory) so callers passing a custom @@ -419,7 +419,7 @@ def _maybe_backfill_total_tasks( The field defaults to 0 because ``CodeGenState`` is constructed before ``plan_tasks`` produces tasks.json. Backfilling on each load keeps the persisted state in sync with the actual task count without requiring - every call site to remember to update it (see plan A2). + every call site to remember to update it. """ if state.total_tasks > 0: return state @@ -555,7 +555,7 @@ def save_code_gen_state(state: CodeGenState, state_path: Path = STATE_FILE) -> N # which never triggered in practice, leaving the state file at ~880 KB # after a single 100-batch run because every save dumps the full state. # Lowered to 200 KB and we keep the last 20 snapshots so debugging can - # still walk back a few steps without bloating the file (plan E3). + # still walk back a few steps without bloating the file. _COMPACT_THRESHOLD = 200 * 1024 # 200 KB _KEEP_LAST_N = 20 # snapshots retained after compact try: @@ -657,8 +657,7 @@ def skip_current_batch(batch_id: str, state_path: Path = STATE_FILE) -> bool: from being merged, but is not a code-quality failure. The batch_id is recorded in ``skipped_task_ids`` for observability, yet remains absent from ``completed_task_ids`` and ``failed_task_ids`` so the next - ``--next`` invocation re-attempts it without consuming a retry slot - (see plan A3). + ``--next`` invocation re-attempts it without consuming a retry slot. Loop guard: ``batch_prepare_counts[batch_id]`` is incremented on each skip; once it reaches ``_MAX_BATCH_PREPARES`` the batch is recorded diff --git a/RPG-Kit/scripts/common/llm_client.py b/RPG-Kit/scripts/common/llm_client.py index abf6e93..f610651 100644 --- a/RPG-Kit/scripts/common/llm_client.py +++ b/RPG-Kit/scripts/common/llm_client.py @@ -36,39 +36,24 @@ def _set_pdeathsig() -> None: # ---------------------------------------------------------------------------- -# AI CLI command resolution (added in rpgkit-cli 0.1.3) +# AI CLI command resolution # ---------------------------------------------------------------------------- # -# Pre-0.1.3 each release-zip variant pre-substituted ```` for -# the chosen agent at packaging time, so this module just exposed the -# literal string. Bundle mode does not do that string-substitution dance -# (one bundle serves every AI), so we resolve at runtime instead with a -# P1-P4 priority chain. See plans/01-package-bundle-and-ai-config.md §3. +# Resolution priority: +# P1. LLMClient(tool="...") constructor argument +# P2. RPGKIT_AI_CLI_CMD env var +# P3. /.rpgkit/config.toml [rpgkit].ai_cli_cmd +# P4. _BAKED_IN_VALUE (release-zip builds substitute it at packaging time; +# bundle builds leave the placeholder unchanged) # -# P1. LLMClient(tool="...") — explicit constructor argument -# P2. RPGKIT_AI_CLI_CMD env var — for CI / tests / ad-hoc override -# P3. .rpgkit/config.toml [rpgkit].ai_cli_cmd -# P4. Module-level baked-in value — release-zip CI replaces the -# literal "" with the -# concrete command at packaging -# time, keeping the legacy path -# working transparently. -# -# Returning empty string is OK at module-import time and at LLMClient -# construction; the error is raised lazily in :meth:`LLMClient.generate` -# so unit tests and tools that construct an LLMClient without intending -# to invoke the LLM continue to work. - -# Sentinel value used to detect whether the release-zip CI substituted -# the baked-in value. Built programmatically (string concatenation) -# so that ``sed s||...|g`` — the CI's substitution invocation, -# see .github/workflows/scripts/rpgkit/create-release-packages.sh — does -# not also rewrite this line and break the comparison below. +# An unresolved value is reported lazily by LLMClient.generate, not here, +# so importing the module and constructing an LLMClient without calling +# the LLM both succeed. + +# Built via concatenation so the release-zip's ``sed s||...|`` +# does not rewrite this sentinel. _PLACEHOLDER_LITERAL = "<" + "AI_CLI_CMD" + ">" -# Module-level default — the release-zip CI replaces this exact literal -# with the chosen agent's command at packaging time. Bundle installs -# leave it unchanged and rely on P2/P3 to supply the value. _BAKED_IN_VALUE = "" @@ -83,7 +68,7 @@ def _load_ai_cli_cmd() -> str: :func:`paths._find_workspace_root`, not via the import-frozen :data:`paths.WORKSPACE_ROOT` constant. This matters for long-lived processes that may serve more than one workspace (e.g. a future - global MCP server). See plan §2.5/Z. + global MCP server). """ # P2: env var (highest non-P1 priority — useful in tests and one-off # overrides without editing the workspace config). @@ -104,8 +89,8 @@ def _load_ai_cli_cmd() -> str: if cfg_val: return cfg_val except Exception: - # Defensive: paths resolution, missing tomllib, or malformed TOML - # should never crash an LLMClient construction. Fall through. + # paths resolution, missing tomllib, or malformed TOML must not + # crash LLMClient construction. pass # P4: legacy baked-in value (release-zip-substituted at build time). @@ -241,7 +226,6 @@ def __init__( # that construct an LLMClient without intending to invoke the LLM # keep working. The actual error is raised in :meth:`generate` if # the tool is still empty when a call is attempted. - # Plan §3, decision 19. self.tool = tool if tool is not None else _load_ai_cli_cmd() self.trajectory = trajectory self.step_id = step_id @@ -346,7 +330,6 @@ def generate( # ``self.tool`` so that tests and tools can build an LLMClient # without triggering an LLM invocation, but the moment we are # actually asked to call out, the configuration must be valid. - # Plan §3, decision 19. if not self.tool or self.tool == _PLACEHOLDER_LITERAL: raise RuntimeError( "AI CLI command not configured. Run " diff --git a/RPG-Kit/scripts/common/paths.py b/RPG-Kit/scripts/common/paths.py index 5c0aa3b..cf4d932 100644 --- a/RPG-Kit/scripts/common/paths.py +++ b/RPG-Kit/scripts/common/paths.py @@ -3,22 +3,58 @@ This module contains all file path constants used across RPG-Kit scripts. -Directory layout (workspace == repo): - / ← user's source repo + RPG-Kit data - ├── .rpgkit/ ← scripts, data, state (machine-local) +Directory layout (``~/.rpgkit/`` home storage): + + / ← user's source repo + ├── .rpgkit/ ← minimal marker tree (in workspace) + │ ├── config.toml ← team-shared AI config (committable) + │ └── reports/ ← user-facing artefacts (rpg.html, …) ├── .claude/ or .vscode/ ← agent instructions ├── src/ tests/ … ← project code (user-owned) └── .git/ ← single git repo at the workspace root -All paths under ``.rpgkit/`` and ``.claude/`` are relative to -``WORKSPACE_ROOT``. ``REPO_DIR`` is an alias for ``WORKSPACE_ROOT`` kept for -backwards-compatibility with call sites that use "project repo root" -phrasing; both refer to the same directory. + ~/.rpgkit/ ← user-global storage + └── workspaces// + ├── .meta.toml ← channel, timestamps, version + ├── .git/ ← Plan-03 inner snapshot repo + ├── data/ ← rpg.json, dep_graph.json, … + │ └── trajectory/ + └── logs/ ← *.log, mcp_calls.jsonl, … + +Machine-local data (``data/``, ``logs/``, the inner snapshot ``.git/``) +lives under ``~/.rpgkit/workspaces//`` so it survives independently +of the workspace, never gets accidentally committed, and stays scoped +to one user. The workspace dir keeps only the lightweight, team-shared +files that benefit from being version-controlled alongside the code. + +All constants below resolve at module-import time. ``WORKSPACE_ROOT`` +is discovered once; if you need it to track a different workspace +later in the same process (rare), spawn a subprocess instead of +monkey-patching the module. + +``REPO_DIR`` is an alias for ``WORKSPACE_ROOT`` kept for backwards +compatibility with call sites that use "project repo root" phrasing; +both refer to the same directory. """ import os from pathlib import Path +# Import the home-storage helpers. rpgkit_cli is always installed in +# the same Python environment as the scripts (the wheel ships the +# scripts under ``rpgkit_cli/core_pack/scripts/``), so the import is +# robust to where the script gets invoked from. We keep a fallback +# that mirrors the legacy in-workspace layout in case someone imports +# this module from a standalone python install that doesn't have +# rpgkit_cli on sys.path — e.g. a third-party tool dropping in for +# inspection. +try: + from rpgkit_cli import _storage as _rpgkit_storage # type: ignore[import-not-found] + _HOME_STORAGE_AVAILABLE = True +except Exception: # pragma: no cover - defensive + _rpgkit_storage = None # type: ignore[assignment] + _HOME_STORAGE_AVAILABLE = False + # ============================================================================ # Workspace Root (absolute) @@ -49,8 +85,21 @@ def _find_workspace_root() -> Path: # parent process's environment. This matters for git hooks, which # are spawned by ``git`` (cwd = repo root) from arbitrary parent # contexts that may have set RPGKIT_WORKSPACE long ago. + # + # Use the ``.rpgkit/config.toml`` marker (the canonical workspace + # signal of "this is an rpgkit workspace"). Falling back to just + # ``.rpgkit/`` would still work for newly-init'd workspaces, but + # using ``config.toml`` matches :func:`rpgkit_cli._storage + # .find_workspace_root_from` exactly so the MCP server and pipeline + # scripts agree on the boundary. cwd = Path.cwd().absolute() for cand in [cwd, *cwd.parents]: + if (cand / ".rpgkit" / "config.toml").is_file(): + return cand + # Belt-and-braces fallback: also accept a bare ``.rpgkit/`` + # directory. This lets a freshly-cloned workspace whose + # ``config.toml`` was somehow missing still be discovered + # rather than silently degrading to the env-var path below. if (cand / ".rpgkit").is_dir(): return cand @@ -87,14 +136,12 @@ def _find_workspace_root() -> Path: # ============================================================================ # # Anchor SCRIPTS_DIR to ``__file__``'s parent so the constant resolves -# correctly regardless of how the scripts were deployed: +# correctly regardless of how the scripts were deployed. Scripts live +# inside the installed wheel at +# ``/rpgkit_cli/core_pack/scripts/`` and are invoked via +# ``rpgkit script ``. # -# * Pre-0.1.3 layout: scripts copied into ``/.rpgkit/scripts/``. -# * Post-0.1.3 layout: scripts live inside the installed wheel at -# ``/rpgkit_cli/core_pack/scripts/`` and are invoked -# via ``rpgkit script `` (see plan 02). -# -# In both cases the surrounding ``common/`` package is at +# The surrounding ``common/`` package is at # ``SCRIPTS_DIR/common/``, so ``Path(__file__).parent.parent`` is the # scripts root. Callers that need to spawn or sys.path-insert sibling # code (e.g. ``rpg_edit/impact.py``) get a working path automatically. @@ -131,7 +178,7 @@ def cmd_for(script_relpath: str) -> str: A shell-ready string such as ``"rpgkit script run_batch.py"``. Use this for any ``next_action`` hint or error message that - suggests the user run a script. After plan 02, the workspace no + suggests the user run a script. The workspace no longer hosts a ``.rpgkit/scripts/`` copy, so the historic ``python3 .rpgkit/scripts/X.py`` form would fail; ``rpgkit script X.py`` works regardless of workspace layout. @@ -140,12 +187,32 @@ def cmd_for(script_relpath: str) -> str: # ============================================================================ -# .rpgkit Directory Structure (absolute, derived from WORKSPACE_ROOT) -# ============================================================================ +# .rpgkit Directory Structure (runtime state in user home) +# ========================================================================== +# +# Layout: +# +# RPGKIT_DIR = /.rpgkit/ (minimal marker tree: config.toml + .source) +# DATA_DIR = ~/.rpgkit/workspaces//data/ +# LOGS_DIR = ~/.rpgkit/workspaces//logs/ +# REPORTS_DIR = /.rpgkit/reports/ (kept in workspace by +# design: small, user-facing, may be git-tracked) +# +# Falling back to the legacy in-workspace paths when ``_storage`` is +# unavailable keeps this module importable from third-party tools that +# don't ship rpgkit_cli in the same env. RPGKIT_DIR = WORKSPACE_ROOT / ".rpgkit" -DATA_DIR = RPGKIT_DIR / "data" -LOGS_DIR = RPGKIT_DIR / "logs" + +if _HOME_STORAGE_AVAILABLE and _rpgkit_storage is not None: + DATA_DIR = _rpgkit_storage.workspace_data_dir(WORKSPACE_ROOT) + LOGS_DIR = _rpgkit_storage.workspace_logs_dir(WORKSPACE_ROOT) + REPORTS_DIR = _rpgkit_storage.workspace_reports_dir(WORKSPACE_ROOT) +else: + DATA_DIR = RPGKIT_DIR / "data" + LOGS_DIR = RPGKIT_DIR / "logs" + REPORTS_DIR = RPGKIT_DIR / "reports" + COPILOT_LOGS_DIR = LOGS_DIR / "copilot" CLAUDE_LOGS_DIR = LOGS_DIR / "claude" @@ -199,6 +266,14 @@ def cmd_for(script_relpath: str) -> str: DEP_GRAPH_FILE = DATA_DIR / "dep_graph.json" REPO_INFO_FILE = DATA_DIR / "repo_info.json" +# rpg.html lives in REPORTS_DIR (workspace-side) rather than next to +# rpg.json (home-side) because the HTML is a *user-facing* artefact - +# something the developer opens in a browser and may want to share / +# commit alongside the source. Keeping it in ``.rpgkit/reports/`` also +# means double-clicking it from a file explorer "just works" without +# having to dig into ``~/.rpgkit/workspaces//``. +RPG_HTML_FILE = REPORTS_DIR / "rpg.html" + # ============================================================================ # Task Planning & Execution @@ -208,6 +283,19 @@ def cmd_for(script_relpath: str) -> str: CODE_GEN_STATE_FILE = DATA_DIR / "code_gen_state.jsonl" +# ============================================================================ +# RPG Edit (surgical edit pipeline) — well-known artefact locations under +# ``DATA_DIR``. Scripts default their ``--plan`` / ``--impact`` arguments +# to these paths so slash-command templates don't need to know the +# physical (home-dir) location of the workspace. +# ============================================================================ + +RPG_EDIT_PLAN_FILE = DATA_DIR / "rpg_edit_plan.json" +RPG_EDIT_IMPACT_FILE = DATA_DIR / "rpg_edit_impact.json" +RPG_EDIT_CODE_RESULT_FILE = DATA_DIR / "rpg_edit_code_result.json" +RPG_EDIT_REVIEW_RESULT_FILE = DATA_DIR / "rpg_edit_review_result.json" + + # ============================================================================ # Trajectory & Logging # ============================================================================ @@ -221,7 +309,7 @@ def cmd_for(script_relpath: str) -> str: MCP_CALLS_LOG = LOGS_DIR / "mcp_calls.jsonl" HOOK_CALLS_LOG = LOGS_DIR / "hook_calls.jsonl" -REPORTS_DIR = RPGKIT_DIR / "reports" +# REPORTS_DIR is defined above (workspace-local in the new layout). # ============================================================================ @@ -229,7 +317,18 @@ def cmd_for(script_relpath: str) -> str: # ============================================================================ def ensure_rpgkit_dir() -> Path: - """Ensure .rpgkit/data directory exists and return its path.""" + """Ensure ``DATA_DIR`` exists and return its path. + + In the home-storage layout, ``DATA_DIR`` lives under + ``~/.rpgkit/workspaces//data/``. We only create the leaf + directory here; full home-layout bootstrap (including + ``.meta.toml``) is the responsibility of ``rpgkit init`` / + ``rpgkit update``. Calling this from a script that lands in a + workspace without a meta file is supported — the data dir still + gets created and the script can write its output — but the + workspace won't be properly registered until the user runs + ``rpgkit update`` (or ``init``). + """ DATA_DIR.mkdir(parents=True, exist_ok=True) return DATA_DIR diff --git a/RPG-Kit/scripts/common/project_types.py b/RPG-Kit/scripts/common/project_types.py index 03e144d..c705ece 100644 --- a/RPG-Kit/scripts/common/project_types.py +++ b/RPG-Kit/scripts/common/project_types.py @@ -6,8 +6,6 @@ tokens to decide whether to inject web-specific guidance, GUI tooling, data-pipeline checks, etc. -See ``plans/20260508-1-rpgkit-optimization*.md`` § B3 for the full design -and acceptance criteria. This module is intentionally tiny — no dependency on RPG/dataflow code so it stays cheap to import from validation utilities. diff --git a/RPG-Kit/scripts/common/rpg_io.py b/RPG-Kit/scripts/common/rpg_io.py new file mode 100644 index 0000000..e29e24f --- /dev/null +++ b/RPG-Kit/scripts/common/rpg_io.py @@ -0,0 +1,263 @@ +"""Atomic write and corruption-recovery helpers for ``rpg.json``. + +``rpg.json`` is the central pipeline artefact; corruption blocks every +downstream stage. Two failure modes have hurt users in the past: + +1. **Interrupted writes** — encoder dumps the full JSON in one + ``json.dump`` call. If the process is killed (Ctrl-C, OOM, power + loss) mid-write, the file is left half-truncated and every + subsequent read raises ``JSONDecodeError``. The workspace is + effectively bricked until the user re-runs the encoder. + +2. **Silent corruption with no recovery path** — once truncated, the + only "fix" was to re-encode from scratch. But the inner-git + snapshot repo already holds the previous good state at + ``~/.rpgkit/workspaces//.git/``; we just weren't using it. + +This module fixes both with two complementary primitives: + +* :func:`atomic_write_rpg` — serialise to ``.tmp`` first, then + ``os.replace()`` into place. POSIX (and Windows since 2018) + guarantee the rename is atomic, so any reader either sees the + complete previous version or the complete new one — never a partial + write. +* :func:`safe_load_rpg` — on ``JSONDecodeError`` (corruption), walk + the inner-git history of the workspace looking for the most recent + commit where the file parsed cleanly, restore it on disk (so + subsequent callers don't pay the recovery cost), emit a single + warning to ``logging``, and return the recovered data. If no good + snapshot exists, the original ``JSONDecodeError`` is re-raised so + callers can decide how to degrade. + +Design constraints +------------------ + +* No new dependencies; uses ``os`` + ``subprocess`` + ``json``. +* Recovery is best-effort: a failure to invoke git, a missing inner + repo, or a missing good snapshot all fall through cleanly to + re-raising the original parse error. +* The recovered file is written back atomically (same + :func:`atomic_write_rpg` path) so the workspace doesn't relapse on + the next read. +* Logging uses ``logging.getLogger(__name__)`` so calls from scripts + that have configured logging will surface the warning, while + callers in quiet contexts (e.g. MCP server with stderr-redirect) + won't be perturbed. +""" +from __future__ import annotations + +import json +import logging +import os +import subprocess +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Public: atomic write +# --------------------------------------------------------------------------- + +def atomic_write_rpg( + path: Path | str, + data: Any, + *, + indent: int = 2, + ensure_ascii: bool = False, +) -> None: + """Serialise ``data`` to ``path`` atomically as JSON. + + Writes to ``.tmp`` first then renames into place. If the + write fails mid-way (e.g. disk full), the original file (if any) + remains intact and we clean up the partial ``.tmp``. + + The signature matches ``json.dump`` for indent / ensure_ascii so + callers swapping ``open(path, "w") + json.dump`` for this helper + don't have to rethink their JSON formatting choices. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=indent, ensure_ascii=ensure_ascii) + f.write("\n") + # fsync gives us strong durability guarantees: an os.replace + # immediately after a crash could otherwise expose the + # rename without the bytes if the kernel hadn't flushed. + # On filesystems that don't support fsync (rare), the call + # is a harmless no-op. + try: + f.flush() + os.fsync(f.fileno()) + except OSError: + pass + os.replace(tmp, path) + except Exception: + # Clean up a stray .tmp so the next attempt isn't confused by + # a leftover. Swallowing this secondary error preserves the + # original traceback for the caller. + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass + raise + + +# --------------------------------------------------------------------------- +# Public: safe load with inner-git recovery +# --------------------------------------------------------------------------- + +def safe_load_rpg(path: Path | str) -> Any: + """Parse the JSON at ``path``, with automatic recovery on corruption. + + Behaviour: + + * Success path — file parses cleanly: return the deserialised data, + no side effects. + * Corruption path — ``json.JSONDecodeError`` from the read attempt + triggers :func:`_try_restore_from_inner_git`, which scans the + inner-git repo looking for the most recent commit where + the file was valid JSON. If one is found, the file is rewritten + on disk (atomically) with that content, a warning is logged, and + the recovered data is returned. + * Unrecoverable path — no inner git, no valid history, or git + unavailable: the original ``JSONDecodeError`` is re-raised. + * Missing file: ``FileNotFoundError`` is propagated unchanged + (recovery is for *corruption*, not for never-encoded + workspaces). + """ + path = Path(path) + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError as exc: + recovered = _try_restore_from_inner_git(path, exc) + if recovered is None: + # Recovery failed — surface the original parse error so the + # caller can decide how to react (MCP server returns + # ``rpg_unavailable``; scripts may want to abort). + raise + return recovered + + +# --------------------------------------------------------------------------- +# Internal: inner-git recovery +# --------------------------------------------------------------------------- + +# Filenames inside the inner-git repo that we know how to recover. +# Mirrors the layout produced by :mod:`rpgkit_cli._inner_git`: +# ``data/rpg.json``, ``data/dep_graph.json``, etc. +def _git_relpath_for(path: Path) -> Optional[str]: + """Return the path relative to the home-workspace dir for git lookup. + + ``rpg.json`` lives at ``~/.rpgkit/workspaces//data/rpg.json``; + the inner git repo is rooted at ``~/.rpgkit/workspaces//``, + so the path we ``git checkout`` is ``data/rpg.json``. Falls back + to ``None`` when ``path`` doesn't look like it lives under such a + home dir (e.g. test fixtures passing absolute paths into ``/tmp``). + """ + parts = path.resolve().parts + # Look for ".rpgkit/workspaces//..." in the path's components. + try: + idx = parts.index(".rpgkit") + if ( + idx + 2 < len(parts) + and parts[idx + 1] == "workspaces" + # parts[idx+2] is the hash + ): + return "/".join(parts[idx + 3 :]) + except ValueError: + pass + return None + + +def _inner_git_dir_for(path: Path) -> Optional[Path]: + """Find the home-workspace dir (containing ``.git/``) for ``path``.""" + cur = path.resolve().parent + while True: + if (cur / ".git").is_dir() and cur.parent.name == "workspaces": + return cur + if cur.parent == cur: + return None + cur = cur.parent + + +def _try_restore_from_inner_git( + path: Path, original_exc: json.JSONDecodeError +) -> Optional[Any]: + """Recover ``path`` from inner-git; return data or None on failure. + + Walks the linear history of the inner repo from HEAD backwards, + fetching the file content at each commit via ``git show``. The + first commit where the content parses as valid JSON wins. When + a winner is found we also re-write the file on disk (atomically) + so subsequent reads don't pay the recovery cost. + """ + git_dir = _inner_git_dir_for(path) + if git_dir is None: + return None + relpath = _git_relpath_for(path) + if relpath is None: + return None + from shutil import which + if which("git") is None: + return None + + # Force English git messages (consistent with _inner_git.py). + env = {**os.environ, "LC_ALL": "C", "LANG": "C"} + + # Walk linear history (most recent first). ``--follow`` keeps + # working when a script ever renames data files in the future. + try: + log = subprocess.run( + ["git", "-C", str(git_dir), "log", "--format=%H", "--", relpath], + capture_output=True, text=True, env=env, timeout=10, + ) + except (subprocess.SubprocessError, OSError): + return None + if log.returncode != 0: + return None + + commits = [c.strip() for c in log.stdout.splitlines() if c.strip()] + for commit in commits: + try: + show = subprocess.run( + ["git", "-C", str(git_dir), "show", f"{commit}:{relpath}"], + capture_output=True, text=True, env=env, timeout=10, + ) + except (subprocess.SubprocessError, OSError): + continue + if show.returncode != 0: + continue + try: + data = json.loads(show.stdout) + except json.JSONDecodeError: + # Older snapshot also broken — skip and keep walking. + continue + + # Found a good snapshot — restore it on disk + return. + try: + atomic_write_rpg(path, data) + except OSError: + # If we can't write back (read-only fs?), still return the + # recovered data so the caller can proceed; the next + # successful write will heal the file on disk. + pass + + logger.warning( + "rpg-io: %s was corrupted (%s at line %d col %d); auto-restored " + "from inner-git snapshot %s. Run `rpgkit version` to see the " + "exact inner-git path.", + path, + original_exc.msg, + original_exc.lineno, + original_exc.colno, + commit[:8], + ) + return data + + return None diff --git a/RPG-Kit/scripts/feature_build_validation.py b/RPG-Kit/scripts/feature_build_validation.py index 1837f02..09e7259 100644 --- a/RPG-Kit/scripts/feature_build_validation.py +++ b/RPG-Kit/scripts/feature_build_validation.py @@ -146,7 +146,7 @@ def validate_input_file() -> Dict[str, Any]: "project_notes": meta_dict.get("project_notes"), } - # Validate project_types / project_notes (plan B3). Soft-fail with + # Validate project_types / project_notes. Soft-fail with # an error entry so the operator regenerates feature_spec, but # don't prevent legacy specs (without these fields) from running # through downstream stages — they will simply miss the project- @@ -167,7 +167,7 @@ def validate_input_file() -> Dict[str, Any]: logger = logging.getLogger(__name__) logger.warning( "feature_spec.meta is missing project_types/project_notes " - "(plan B3); downstream prompts will lack project-type context" + "; downstream prompts will lack project-type context" ) if result["fields"]["functional_requirements"]: diff --git a/RPG-Kit/scripts/feature_spec_to_json.py b/RPG-Kit/scripts/feature_spec_to_json.py index 157c75f..9255435 100644 --- a/RPG-Kit/scripts/feature_spec_to_json.py +++ b/RPG-Kit/scripts/feature_spec_to_json.py @@ -25,6 +25,15 @@ from pathlib import Path from typing import Optional +# Use the canonical paths from common.paths so the output location +# matches what downstream stages (feature_build, feature_build_validation, +# ...) expect. That resolves to +# ``~/.rpgkit/workspaces//data/feature_spec.json`` rather than the +# workspace-local ``.rpgkit/data/feature_spec.json`` this script used +# to compute on its own — a mismatch that previously broke the +# feature_spec → feature_build handoff. +from common.paths import FEATURE_SPEC_FILE + def parse_evidence_line(line: str) -> Optional[dict]: """Parse an evidence reference line. @@ -390,8 +399,10 @@ def main(): if args.output: output_file = args.output else: - # Default output is in parent directory of input_dir - output_file = input_dir.parent / "feature_spec.json" + # Default to the canonical location from common.paths so + # downstream stages (feature_build) can find it. The output + # lives in the home-side data dir. + output_file = FEATURE_SPEC_FILE include_evidence = not args.no_evidence diff --git a/RPG-Kit/scripts/init_codebase.py b/RPG-Kit/scripts/init_codebase.py index dec717a..bba85c6 100644 --- a/RPG-Kit/scripts/init_codebase.py +++ b/RPG-Kit/scripts/init_codebase.py @@ -36,7 +36,6 @@ REPO_RPG_FILE, FEATURE_BUILD_FILE, CODE_GEN_STATE_FILE as STATE_FILE, - get_scripts_dir, cmd_for, REPO_DIR, ) @@ -214,7 +213,7 @@ def _gitignore_has_rpgkit_block(existing: str) -> bool: # Agent Detection & Persistent Instructions # ============================================================================ # -# Removed in commit C4 (see plans/20260508-1-rpgkit-optimization*.md): the +# Removed: the # previously-generated `repo/.claude/rules/rpgkit-codegen.md` and # `repo/.github/instructions/rpgkit-codegen.instructions.md` files were # auto-loaded by Claude Code / Copilot for **every** session, contaminating @@ -522,14 +521,13 @@ def init_codebase( # IS the project repo root, so ``.claude`` is already at the right # location and the symlink is unnecessary (and would point at # ``/.claude``, i.e. outside the workspace). - # Block removed deliberately; do NOT reintroduce. + # Block removed on purpose; do not reintroduce. # Check if already initialized if state_path.exists(): try: state = load_code_gen_state(state_path) if state.initialized: - scripts = get_scripts_dir() return { "success": False, "error": "Codebase already initialized", @@ -592,7 +590,6 @@ def init_codebase( state.initialized = True state.initialized_at = datetime.now().isoformat() save_code_gen_state(state, state_path) - scripts = get_scripts_dir() return { "success": True, "message": "Repository already set up, no changes needed", diff --git a/RPG-Kit/scripts/mcp_server.py b/RPG-Kit/scripts/mcp_server.py index 2fd93a1..e6f3d05 100644 --- a/RPG-Kit/scripts/mcp_server.py +++ b/RPG-Kit/scripts/mcp_server.py @@ -75,7 +75,17 @@ def _log_tool_call(tool_name: str, params: dict, result_summary: dict, duration_ # --------------------------------------------------------------------------- def _resolve_rpg_path() -> str: - """Resolve RPG file path from CLI args or default (.rpgkit/data/rpg.json).""" + """Resolve the RPG file path from CLI args, falling back to the default. + + The default (``RPG_FILE``) is provided by + :mod:`common.paths`, which resolves to + ``~/.rpgkit/workspaces//data/rpg.json`` for the current + workspace (discovered by walking up from cwd looking for + ``.rpgkit/config.toml``). Callers running ``rpgkit-mcp`` from any + subdirectory of a workspace therefore get the right RPG file + automatically; ``--rpg-file`` is reserved for explicit overrides + (test fixtures, alternative graphs, …). + """ rpg_path = str(RPG_FILE) args = sys.argv[1:] for i, arg in enumerate(args): @@ -86,11 +96,13 @@ def _resolve_rpg_path() -> str: # Standard message returned to the AI agent when the RPG graph isn't ready # (e.g. ``rpgkit init`` ran, but the encoder hasn't been run yet so -# ``.rpgkit/data/rpg.json`` doesn't exist). Kept short + actionable so -# the agent will relay it verbatim to the user. +# the resolved ``rpg.json`` doesn't exist). Kept short + actionable so +# the agent will relay it verbatim to the user. The hint omits the +# concrete directory path; the actual location is reported as the +# ``rpg_file`` field of :func:`_unavailable_payload`. _ENCODE_HINT = ( "RPG graph not generated yet. Ask the user to run **`/rpgkit.encode`** " - "in this AI agent to build `.rpgkit/data/rpg.json`. Once it finishes, " + "in this AI agent to build the workspace's `rpg.json`. Once it finishes, " "RPG tools will start working automatically on the next call — no need " "to restart the MCP server." ) @@ -99,7 +111,7 @@ def _resolve_rpg_path() -> str: def _unavailable_payload(rpg_path: str, reason: str) -> str: """Render a uniform 'graph not available' JSON response for every tool. - The shape is deliberately identical across all 4 tools so the AI agent + The shape is identical across all 4 tools so the AI agent can reliably detect the condition (``error == "rpg_unavailable"``) and surface the ``next_step`` field to the user. """ diff --git a/RPG-Kit/scripts/plan_tasks.py b/RPG-Kit/scripts/plan_tasks.py index 4861149..1d3a891 100644 --- a/RPG-Kit/scripts/plan_tasks.py +++ b/RPG-Kit/scripts/plan_tasks.py @@ -675,7 +675,7 @@ def plan_subtree_tasks( # ============================================================================ def _format_entry_file_hint(project_types: List[str]) -> str: - """Render a per-project-type hint about the entry filename (plan B4). + """Render a per-project-type hint about the entry filename. Returns a single-line string appended to the "main.py" bullet of the main-entry task description. The wording stays advisory — the agent @@ -1286,7 +1286,7 @@ def _build_main_entry_task(self) -> str: # Read project_types so we can hint at the right entry-file shape # without locking the agent into "main.py" for SERVICE/PIPELINE - # projects (plan B4). Failure to load is non-fatal — fall back to + # projects. Failure to load is non-fatal — fall back to # the generic guidance. project_types = self._load_project_types() entry_hint = _format_entry_file_hint(project_types) @@ -1365,7 +1365,7 @@ def main(args: Optional[list] = None) -> int: """ def _load_project_types(self) -> List[str]: - """Load ``feature_spec.meta.project_types`` if available (plan B4). + """Load ``feature_spec.meta.project_types`` if available. Returns an empty list when the file is missing, malformed, or has no valid tokens. Callers must handle the empty case. diff --git a/RPG-Kit/scripts/rpg/graph_query.py b/RPG-Kit/scripts/rpg/graph_query.py index d56733b..734d07f 100644 --- a/RPG-Kit/scripts/rpg/graph_query.py +++ b/RPG-Kit/scripts/rpg/graph_query.py @@ -21,6 +21,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from common.rpg_io import safe_load_rpg + try: from rapidfuzz import fuzz, process as rf_process _HAS_RAPIDFUZZ = True @@ -96,10 +98,14 @@ def from_rpg_file(cls, rpg_path: str) -> "GraphQueryEngine": """Load from a single rpg.json file. Handles both embedded dep_graph and external dep_graph_file reference. + + Uses :func:`common.rpg_io.safe_load_rpg` so a corrupted ``rpg.json`` + (e.g. an encoder that was killed mid-write) is silently recovered + from the inner-git snapshot history rather than blocking every + downstream read with ``JSONDecodeError``. """ rpg_dir = Path(rpg_path).resolve().parent - with open(rpg_path, "r", encoding="utf-8") as f: - rpg_data = json.load(f) + rpg_data = safe_load_rpg(rpg_path) # Try embedded dep_graph first, then external file dep_graph_data = rpg_data.get("dep_graph", {}) @@ -108,8 +114,7 @@ def from_rpg_file(cls, rpg_path: str) -> "GraphQueryEngine": if dep_graph_file: dep_path = rpg_dir / dep_graph_file if dep_path.is_file(): - with open(dep_path, "r", encoding="utf-8") as f: - dep_graph_data = json.load(f) + dep_graph_data = safe_load_rpg(dep_path) logger.info("Loaded dep_graph from %s", dep_path) else: logger.warning("dep_graph_file not found: %s", dep_path) @@ -119,11 +124,9 @@ def from_rpg_file(cls, rpg_path: str) -> "GraphQueryEngine": @classmethod def from_files(cls, rpg_path: str, dep_graph_path: str = "") -> "GraphQueryEngine": """Load from JSON files. If dep_graph_path is empty, uses embedded dep_graph.""" - with open(rpg_path, "r", encoding="utf-8") as f: - rpg_data = json.load(f) + rpg_data = safe_load_rpg(rpg_path) if dep_graph_path: - with open(dep_graph_path, "r", encoding="utf-8") as f: - dep_graph_data = json.load(f) + dep_graph_data = safe_load_rpg(dep_graph_path) else: dep_graph_data = rpg_data.get("dep_graph", {}) return cls(rpg_data, dep_graph_data) diff --git a/RPG-Kit/scripts/rpg/models.py b/RPG-Kit/scripts/rpg/models.py index 90564ef..d399d6f 100644 --- a/RPG-Kit/scripts/rpg/models.py +++ b/RPG-Kit/scripts/rpg/models.py @@ -712,9 +712,9 @@ def _infer_missing_node_type(self, node: Node) -> Optional[str]: 2) Feature-tree shape (category/subcategory/feature_group/feature) Note: ``meta.type_name`` (file/class/function/method/directory) is - the *code entity type*, NOT the tree-level role. We never use it - to set ``node_type`` — that would mix two orthogonal concepts. - Instead we rely on tree structure (leaf vs non-leaf, parent type). + the *code entity type*, not the tree-level role. ``node_type`` + is derived from tree structure (leaf vs non-leaf, parent type) + because the two concepts are orthogonal. """ if node.id == self.repo_node.id: return "repo" diff --git a/RPG-Kit/scripts/rpg_edit/apply.py b/RPG-Kit/scripts/rpg_edit/apply.py index c9bc0c4..deddd54 100644 --- a/RPG-Kit/scripts/rpg_edit/apply.py +++ b/RPG-Kit/scripts/rpg_edit/apply.py @@ -21,7 +21,7 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, REPO_DIR # noqa: E402 +from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, REPO_DIR, RPG_EDIT_PLAN_FILE # noqa: E402 def _backup(rpg_path: Path, dep_graph_path: Path, ts: str) -> Dict[str, str]: @@ -112,8 +112,8 @@ def apply_feature_changes(svc, changes: list) -> list: def main(): parser = argparse.ArgumentParser(description="Apply EditPlan to RPG + code") - parser.add_argument("--plan", type=Path, required=True, - help="Path to rpg_edit_plan.json") + parser.add_argument("--plan", type=Path, default=RPG_EDIT_PLAN_FILE, + help="Path to rpg_edit_plan.json (default: %(default)s)") parser.add_argument("--rpg", type=Path, default=REPO_RPG_FILE) parser.add_argument("--dep-graph", type=Path, @@ -142,7 +142,6 @@ def main(): args = parser.parse_args() # Capture log records for post-mortem inspection of rpg_edit issues. - # See plans/20260508-1-rpgkit-optimization*.md § E1. from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") diff --git a/RPG-Kit/scripts/rpg_edit/code.py b/RPG-Kit/scripts/rpg_edit/code.py index 3a69ba6..6ce192d 100644 --- a/RPG-Kit/scripts/rpg_edit/code.py +++ b/RPG-Kit/scripts/rpg_edit/code.py @@ -41,9 +41,10 @@ from common.paths import ( # noqa: E402 RPG_FILE, + REPO_DIR, + RPG_EDIT_PLAN_FILE, DATA_DIR, WORKSPACE_ROOT, - REPO_DIR, cmd_for, ) from common.logging_setup import setup_file_logging # noqa: E402 @@ -646,8 +647,8 @@ def main() -> int: description="Apply EditPlan code_changes via SubAgent (RPG-driven)", ) parser.add_argument( - "--plan", type=Path, required=True, - help="Path to rpg_edit_plan.json", + "--plan", type=Path, default=RPG_EDIT_PLAN_FILE, + help="Path to rpg_edit_plan.json (default: %(default)s)", ) parser.add_argument( "--rpg", type=Path, default=RPG_FILE, diff --git a/RPG-Kit/scripts/rpg_edit/impact.py b/RPG-Kit/scripts/rpg_edit/impact.py index cf39897..2253199 100644 --- a/RPG-Kit/scripts/rpg_edit/impact.py +++ b/RPG-Kit/scripts/rpg_edit/impact.py @@ -17,7 +17,7 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_RPG_FILE # noqa: E402 +from common.paths import REPO_RPG_FILE, RPG_EDIT_IMPACT_FILE # noqa: E402 def analyze_impact(svc, node_ids: List[str]) -> Dict: @@ -117,10 +117,13 @@ def main(): parser.add_argument("--rpg", type=Path, default=REPO_RPG_FILE) parser.add_argument("--json", action="store_true") + parser.add_argument("--save", action="store_true", + help=f"Also write the JSON result to " + f"{RPG_EDIT_IMPACT_FILE} so downstream " + f"steps (review.py) can pick it up.") args = parser.parse_args() # Capture log records for post-mortem inspection of rpg_edit issues. - # See plans/20260508-1-rpgkit-optimization*.md § E1. from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") @@ -129,6 +132,11 @@ def main(): results = analyze_impact(svc, args.node_id) output = {"type": "impact_analysis", "results": results} + if args.save: + RPG_EDIT_IMPACT_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_EDIT_IMPACT_FILE.write_text( + json.dumps(output, indent=2, ensure_ascii=False) + ) if args.json: print(json.dumps(output, indent=2, ensure_ascii=False)) else: diff --git a/RPG-Kit/scripts/rpg_edit/locate.py b/RPG-Kit/scripts/rpg_edit/locate.py index ca9333e..082b819 100644 --- a/RPG-Kit/scripts/rpg_edit/locate.py +++ b/RPG-Kit/scripts/rpg_edit/locate.py @@ -159,7 +159,6 @@ def main(): args = parser.parse_args() # Capture log records for post-mortem inspection of rpg_edit issues. - # See plans/20260508-1-rpgkit-optimization*.md § E1. from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") diff --git a/RPG-Kit/scripts/rpg_edit/review.py b/RPG-Kit/scripts/rpg_edit/review.py index e0b071b..c5ac4ef 100644 --- a/RPG-Kit/scripts/rpg_edit/review.py +++ b/RPG-Kit/scripts/rpg_edit/review.py @@ -34,7 +34,7 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_DIR, cmd_for # noqa: E402 +from common.paths import REPO_DIR, cmd_for, RPG_EDIT_PLAN_FILE, RPG_EDIT_IMPACT_FILE # noqa: E402 logger = logging.getLogger(__name__) @@ -547,10 +547,10 @@ def main(): parser = argparse.ArgumentParser( description="Impact-scoped review for rpg_edit changes" ) - parser.add_argument("--plan", type=Path, required=True, - help="Path to rpg_edit_plan.json") - parser.add_argument("--impact", type=Path, default=None, - help="Path to rpg_edit_impact.json") + parser.add_argument("--plan", type=Path, default=RPG_EDIT_PLAN_FILE, + help="Path to rpg_edit_plan.json (default: %(default)s)") + parser.add_argument("--impact", type=Path, default=RPG_EDIT_IMPACT_FILE, + help="Path to rpg_edit_impact.json (default: %(default)s)") parser.add_argument("--repo", type=Path, default=None, help="Repository root path") parser.add_argument("--max-iterations", type=int, default=3, @@ -567,7 +567,6 @@ def main(): ) # Capture log records for post-mortem inspection of rpg_edit issues. - # See plans/20260508-1-rpgkit-optimization*.md § E1. from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") diff --git a/RPG-Kit/scripts/rpg_edit/save_plan.py b/RPG-Kit/scripts/rpg_edit/save_plan.py new file mode 100644 index 0000000..4c0e736 --- /dev/null +++ b/RPG-Kit/scripts/rpg_edit/save_plan.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Save an EditPlan JSON document to ``RPG_EDIT_PLAN_FILE``. + +Reads JSON from stdin, validates that it parses, and writes it to +``~/.rpgkit/workspaces//data/rpg_edit_plan.json``. Slash-command +templates use this so they never need to know the physical (home-dir) +location of the workspace. + +Usage (typical AI-agent invocation):: + + cat << 'PLAN_EOF' | rpgkit script rpg_edit/save_plan.py + { "feature_changes": [...], "code_changes": [...] } + PLAN_EOF + +On success prints the absolute path of the saved file (one line) on +stdout and exits 0. On JSON parse error exits 2 with the parser +message on stderr. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).resolve().parent.parent +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from common.paths import RPG_EDIT_PLAN_FILE # noqa: E402 + + +def main() -> int: + if any(arg in ("-h", "--help") for arg in sys.argv[1:]): + print(__doc__) + print(f"Output path: {RPG_EDIT_PLAN_FILE}") + return 0 + raw = sys.stdin.read() + if not raw.strip(): + print("save_plan: stdin is empty", file=sys.stderr) + return 2 + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + print(f"save_plan: invalid JSON on stdin: {exc}", file=sys.stderr) + return 2 + RPG_EDIT_PLAN_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_EDIT_PLAN_FILE.write_text( + json.dumps(parsed, indent=2, ensure_ascii=False) + ) + print(str(RPG_EDIT_PLAN_FILE)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/RPG-Kit/scripts/rpg_edit/validate.py b/RPG-Kit/scripts/rpg_edit/validate.py index 6ca3b5f..8ad5a96 100644 --- a/RPG-Kit/scripts/rpg_edit/validate.py +++ b/RPG-Kit/scripts/rpg_edit/validate.py @@ -25,7 +25,6 @@ def main(): args = parser.parse_args() # Capture log records for post-mortem inspection of rpg_edit issues. - # See plans/20260508-1-rpgkit-optimization*.md § E1. from common.logging_setup import setup_file_logging setup_file_logging("rpg_edit") diff --git a/RPG-Kit/scripts/rpg_encoder/run_encode.py b/RPG-Kit/scripts/rpg_encoder/run_encode.py index 36409b6..f552f52 100644 --- a/RPG-Kit/scripts/rpg_encoder/run_encode.py +++ b/RPG-Kit/scripts/rpg_encoder/run_encode.py @@ -25,7 +25,7 @@ if str(_script_dir) not in sys.path: sys.path.insert(0, str(_script_dir)) -from common.paths import RPG_FILE, DEP_GRAPH_FILE, WORKSPACE_ROOT, ensure_rpgkit_dir # noqa: E402 +from common.paths import RPG_FILE, DEP_GRAPH_FILE, RPG_HTML_FILE, WORKSPACE_ROOT, ensure_rpgkit_dir # noqa: E402 from common.trajectory import Trajectory # noqa: E402 @@ -164,8 +164,12 @@ def run_encode( viz_data = load_rpg(output) html_content = generate_html(viz_data) - viz_output = str(Path(output).with_suffix(".html")) - Path(viz_output).write_text(html_content, encoding="utf-8") + # rpg.html is a user-facing artefact: keep it in the + # workspace's .rpgkit/reports/ rather than next to the + # machine-side rpg.json under ~/.rpgkit/workspaces//. + RPG_HTML_FILE.parent.mkdir(parents=True, exist_ok=True) + viz_output = str(RPG_HTML_FILE) + RPG_HTML_FILE.write_text(html_content, encoding="utf-8") traj.complete_step(step_viz.step_id, {"viz_path": viz_output}) except Exception as viz_exc: logger.warning("Failed to generate visualization: %s", viz_exc) diff --git a/RPG-Kit/scripts/rpg_encoder/version_control.py b/RPG-Kit/scripts/rpg_encoder/version_control.py index b48a757..d2c7252 100644 --- a/RPG-Kit/scripts/rpg_encoder/version_control.py +++ b/RPG-Kit/scripts/rpg_encoder/version_control.py @@ -26,6 +26,8 @@ from typing import Any, Dict, List, Optional from rpg import RPG +from pathlib import Path +from common.rpg_io import atomic_write_rpg logger = logging.getLogger(__name__) @@ -173,11 +175,12 @@ def rollback(self, version: int) -> RPG: rpg = RPG.from_dict(payload["rpg"]) - # Also write to the main rpg.json so it becomes the "current" RPG + # Also write to the main rpg.json so it becomes the "current" RPG. + # Atomic write: a kill mid-rollback can't leave a half-truncated + # rpg.json that bricks future reads. main_rpg_path = os.path.join(self.data_dir, RPG_FILE_NAME) os.makedirs(self.data_dir, exist_ok=True) - with open(main_rpg_path, "w", encoding="utf-8") as fh: - json.dump(payload["rpg"], fh, indent=2, ensure_ascii=False) + atomic_write_rpg(Path(main_rpg_path), payload["rpg"]) logger.info( "Rolled back to version %d (%s)", diff --git a/RPG-Kit/scripts/rpg_encoder/workflow.py b/RPG-Kit/scripts/rpg_encoder/workflow.py index 798e4f7..56981ed 100644 --- a/RPG-Kit/scripts/rpg_encoder/workflow.py +++ b/RPG-Kit/scripts/rpg_encoder/workflow.py @@ -53,6 +53,7 @@ from .config import RPGKitConfig from .version_control import RPGVersionControl, RPG_FILE_NAME +from common.rpg_io import atomic_write_rpg logger = logging.getLogger(__name__) @@ -333,8 +334,11 @@ def save_rpg( rpg_dict["repo_info"] = getattr(rpg, "repo_info", "") rpg_dict["excluded_files"] = getattr(rpg, "excluded_files", []) - with open(rpg_path, "w", encoding="utf-8") as fh: - json.dump(rpg_dict, fh, indent=2, ensure_ascii=False) + # Atomic write: a partial encoder run (Ctrl-C, OOM, power loss) + # can no longer brick the workspace with a truncated rpg.json + # — we write to .tmp then os.replace into place. See + # ``common.rpg_io.atomic_write_rpg`` for the recovery side. + atomic_write_rpg(Path(rpg_path), rpg_dict) result: Dict[str, Any] = {"rpg_path": rpg_path} diff --git a/RPG-Kit/scripts/run_batch.py b/RPG-Kit/scripts/run_batch.py index 20b8a73..86bbf88 100644 --- a/RPG-Kit/scripts/run_batch.py +++ b/RPG-Kit/scripts/run_batch.py @@ -661,7 +661,7 @@ def run_batch( pass if merge_error == "branch_missing": # Sub-agent didn't use the batch branch — skip without - # consuming a retry slot (see plan A3). The helper + # consuming a retry slot. The helper # promotes to failed after _MAX_BATCH_PREPARES skips. skipped = state_skip_batch(batch_id, state_path) if skipped: @@ -798,7 +798,7 @@ def run_batch( pass if merge_error == "branch_missing": # Sub-agent didn't use the batch branch — skip without - # consuming a retry slot (see plan A3). The helper + # consuming a retry slot. The helper # promotes to failed after _MAX_BATCH_PREPARES skips. skipped = state_skip_batch(batch_id, state_path) if skipped: diff --git a/RPG-Kit/scripts/update_graphs.py b/RPG-Kit/scripts/update_graphs.py index 47a3a2a..3c7fc76 100644 --- a/RPG-Kit/scripts/update_graphs.py +++ b/RPG-Kit/scripts/update_graphs.py @@ -31,7 +31,8 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, HOOK_CALLS_LOG # noqa: E402 +from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, RPG_HTML_FILE, HOOK_CALLS_LOG # noqa: E402 +from common.rpg_io import safe_load_rpg # noqa: E402 # Shared message used by every subcommand that requires an existing @@ -102,9 +103,12 @@ def _refresh_rpg_html(rpg_path: Path) -> dict: data = load_rpg(str(rpg_path)) html_content = generate_html(data) - viz_path = rpg_path.with_suffix(".html") - viz_path.write_text(html_content, encoding="utf-8") - result["viz_path"] = str(viz_path) + # rpg.html is a user-facing artefact: write it to the + # workspace's .rpgkit/reports/ (the home-side data/ holds + # only machine-consumed JSON). This mirrors run_encode.py. + RPG_HTML_FILE.parent.mkdir(parents=True, exist_ok=True) + RPG_HTML_FILE.write_text(html_content, encoding="utf-8") + result["viz_path"] = str(RPG_HTML_FILE) except Exception as exc: # pragma: no cover — defensive result["viz_error"] = str(exc) return result @@ -525,8 +529,10 @@ def cmd_status(rpg_path: Path, dep_graph_path: Path) -> dict: if rpg_path.exists(): try: - with open(rpg_path, "r", encoding="utf-8") as f: - rpg_data = json.load(f) + # Use safe_load_rpg so a corrupted rpg.json doesn't crash + # the cheap status command — it'll silently restore from + # inner-git history when possible. + rpg_data = safe_load_rpg(rpg_path) # RPG stores features in a hierarchical tree rooted at "root". # Walk it lazily to count nodes without loading the full # rpg.service module (the status command must stay cheap). diff --git a/RPG-Kit/templates/commands/build_data_flow.md b/RPG-Kit/templates/commands/build_data_flow.md index 7c6635d..aca1014 100644 --- a/RPG-Kit/templates/commands/build_data_flow.md +++ b/RPG-Kit/templates/commands/build_data_flow.md @@ -81,14 +81,11 @@ Run the script `rpgkit script check_data_flow.py` to verify the current state. 2. Execute the following command with the selected iteration count: ```bash - rpgkit script build_data_flow.py --max-iterations > .rpgkit/logs/build_data_flow.log 2>&1 + rpgkit script build_data_flow.py --max-iterations ``` - Then print the output by: - - ```bash - cat .rpgkit/logs/build_data_flow.log - ``` + The script writes a structured log automatically; + stdout carries the summary you need below. 3. Upon successful completion, display: diff --git a/RPG-Kit/templates/commands/build_skeleton.md b/RPG-Kit/templates/commands/build_skeleton.md index 5b1a550..695721b 100644 --- a/RPG-Kit/templates/commands/build_skeleton.md +++ b/RPG-Kit/templates/commands/build_skeleton.md @@ -69,24 +69,19 @@ Run the script `rpgkit script check_skeleton.py` to verify the current state. 2. Execute the following command with the selected iteration count: ```bash - rpgkit script build_skeleton.py --max-iterations > .rpgkit/logs/build_skeleton.log 2>&1 + rpgkit script build_skeleton.py --max-iterations ``` - Then print the output by: + The script writes a structured log automatically; + stdout carries the human-readable summary you need below. - ```bash - cat .rpgkit/logs/build_skeleton.log - ``` - -3. After the command finishes, read the **entire output** from `.rpgkit/logs/build_skeleton.log`: +3. From the captured stdout, find the section containing: - * Locate the section containing: - - ```text - SKELETON BUILDING COMPLETE - ``` + ```text + SKELETON BUILDING COMPLETE + ``` - * Display the summary information in a Markdown table format showing: + Display the summary information in a Markdown table format showing: * Total components * Total features * Total files created @@ -118,19 +113,18 @@ Run the summary script to generate a formatted report and save to file: rpgkit script summary_skeleton.py ``` -This saves the summary (including directory structure, component paths, and statistics) to `.rpgkit/data/skeleton_summary.txt`. +The summary (including directory structure, component paths, and statistics) is +printed on stdout by `summary_skeleton.py`; the script also persists it +to the workspace's state directory for later inspection. Then prompt the user: ```text Skeleton has been generated. -Generated files: - .rpgkit/data/skeleton.json - Skeleton data (JSON format) - .rpgkit/data/skeleton_summary.txt - Human-readable summary - -To view the skeleton summary: - cat .rpgkit/data/skeleton_summary.txt +Outputs (managed by the script; consumed by downstream stages): + skeleton.json - Skeleton data (JSON format) + skeleton_summary.txt - Human-readable summary To proceed with data flow design, run: /rpgkit.build_data_flow diff --git a/RPG-Kit/templates/commands/design_base_classes.md b/RPG-Kit/templates/commands/design_base_classes.md index ad66071..4bfbafb 100644 --- a/RPG-Kit/templates/commands/design_base_classes.md +++ b/RPG-Kit/templates/commands/design_base_classes.md @@ -66,14 +66,11 @@ Run the script `rpgkit script check_base_classes.py` to verify the current state 2. Execute the following command with the selected iteration count: ```bash - rpgkit script design_base_classes.py --max-iterations > .rpgkit/logs/design_base_classes.log 2>&1 + rpgkit script design_base_classes.py --max-iterations ``` - Then print the output by: - - ```bash - cat .rpgkit/logs/design_base_classes.log - ``` + The script writes a structured log automatically; + stdout carries the summary the next step needs. 3. Upon successful completion, display: diff --git a/RPG-Kit/templates/commands/design_interfaces.md b/RPG-Kit/templates/commands/design_interfaces.md index 5205086..512e5ea 100644 --- a/RPG-Kit/templates/commands/design_interfaces.md +++ b/RPG-Kit/templates/commands/design_interfaces.md @@ -62,14 +62,11 @@ rpgkit script check_interfaces.py --json Run the interface designer: ```bash -rpgkit script design_interfaces.py > .rpgkit/logs/design_interfaces.log 2>&1 +rpgkit script design_interfaces.py ``` -Then print the output by: - -```bash -cat .rpgkit/logs/design_interfaces.log -``` +The script writes a structured log automatically; stdout carries the +summary you need below. This will: diff --git a/RPG-Kit/templates/commands/encode.md b/RPG-Kit/templates/commands/encode.md index 592fb65..7651917 100644 --- a/RPG-Kit/templates/commands/encode.md +++ b/RPG-Kit/templates/commands/encode.md @@ -62,12 +62,13 @@ Inspect the `type` field in the output: Run the full encode script: ```bash -rpgkit script rpg_encoder/run_encode.py --json > .rpgkit/logs/encode.log 2>&1 +rpgkit script rpg_encoder/run_encode.py --json ``` This may take several minutes depending on repository size and LLM response times. -Inspect the encoding result by reading the tail of the log (`tail -n 200 .rpgkit/logs/encode.log`) -or the JSON summary written by the script. +The script prints a JSON summary on stdout and writes a structured +log automatically. +Inspect the JSON `status` field to decide next steps. **If status is "success"**: diff --git a/RPG-Kit/templates/commands/feature_build.md b/RPG-Kit/templates/commands/feature_build.md index 4b29338..569d313 100644 --- a/RPG-Kit/templates/commands/feature_build.md +++ b/RPG-Kit/templates/commands/feature_build.md @@ -61,13 +61,13 @@ The script automatically detects whether the output file (`feature_build.json`) 1. **Execute the command:** ```bash - rpgkit script feature_build.py \ - --mode step1 > .rpgkit/logs/feature_build.log 2>&1 + rpgkit script feature_build.py --mode step1 ``` - Inspect the result by reading the tail of the log - (`tail -n 300 .rpgkit/logs/feature_build.log`) to capture the - `FEATURE EXPANSION SUMMARY` section described below. + The script prints its full output on stdout and also writes a + The script writes a structured log automatically. + Inspect the stdout to capture the `FEATURE EXPANSION SUMMARY` + section described below. **Available parameters for Step 2:** @@ -116,12 +116,11 @@ After the spec-driven build is complete, ask the user whether they want to expan a. **Get expansion direction suggestions:** ```bash - rpgkit script feature_build.py \ - --mode suggest-directions > .rpgkit/logs/feature_build.log 2>&1 + rpgkit script feature_build.py --mode suggest-directions ``` - Read the log to obtain the JSON payload - (`tail -n 200 .rpgkit/logs/feature_build.log`). + The JSON payload is printed on stdout (and the full log is + written automatically). b. **Parse the JSON output** and display the directions as a numbered list to the user: @@ -152,7 +151,7 @@ After the spec-driven build is complete, ask the user whether they want to expan ```bash rpgkit script feature_build.py \ --mode step2 \ - --direction "" > .rpgkit/logs/feature_build.log 2>&1 + --direction "" ``` For example, if the user enters `1,3,5`: @@ -160,7 +159,7 @@ After the spec-driven build is complete, ask the user whether they want to expan ```bash rpgkit script feature_build.py \ --mode step2 \ - --direction "1,3,5" > .rpgkit/logs/feature_build.log 2>&1 + --direction "1,3,5" ``` **What happens inside the script:** diff --git a/RPG-Kit/templates/commands/feature_edit.md b/RPG-Kit/templates/commands/feature_edit.md index 7f7831c..54640d8 100644 --- a/RPG-Kit/templates/commands/feature_edit.md +++ b/RPG-Kit/templates/commands/feature_edit.md @@ -96,14 +96,11 @@ Please confirm to proceed: Execute the following command: ```bash -rpgkit script feature_edit.py > .rpgkit/logs/feature_edit.log 2>&1 +rpgkit script feature_edit.py ``` -Then print the output by: - -```bash -cat .rpgkit/logs/feature_edit.log -``` +The script writes a structured log automatically; stdout +carries the summary you need below. ### Step 4: Summarize Results diff --git a/RPG-Kit/templates/commands/feature_refactor.md b/RPG-Kit/templates/commands/feature_refactor.md index 417d7a2..c307798 100644 --- a/RPG-Kit/templates/commands/feature_refactor.md +++ b/RPG-Kit/templates/commands/feature_refactor.md @@ -47,14 +47,11 @@ name: rpgkit.feature_refactor 2. Execute the following command with the selected max iteration count (default: 10 or user-defined): ```bash - rpgkit script feature_refactor.py --max-iterations > .rpgkit/logs/feature_refactor.log 2>&1 + rpgkit script feature_refactor.py --max-iterations ``` - Then print the output by: - - ```bash - cat .rpgkit/logs/feature_refactor.log - ``` + The script writes a structured log automatically; + stdout carries the summary you need below. 3. Analyze and summarize the information printed during script execution, and present the results in a Markdown table format. diff --git a/RPG-Kit/templates/commands/plan_tasks.md b/RPG-Kit/templates/commands/plan_tasks.md index 70e1a4f..3b1634b 100644 --- a/RPG-Kit/templates/commands/plan_tasks.md +++ b/RPG-Kit/templates/commands/plan_tasks.md @@ -60,14 +60,11 @@ rpgkit script check_tasks.py --json Run the task planner: ```bash -rpgkit script plan_tasks.py > .rpgkit/logs/plan_tasks.log 2>&1 +rpgkit script plan_tasks.py ``` -Then print the output by: - -```bash -cat .rpgkit/logs/plan_tasks.log -``` +The script writes a structured log automatically; stdout carries the +summary you need below. This will: diff --git a/RPG-Kit/templates/commands/rpg_edit.md b/RPG-Kit/templates/commands/rpg_edit.md index f1388af..6f5186b 100644 --- a/RPG-Kit/templates/commands/rpg_edit.md +++ b/RPG-Kit/templates/commands/rpg_edit.md @@ -75,13 +75,16 @@ plus a `tree_summary` showing the full RPG structure for orientation. ### Step 3: Analyze Impact -For each selected node, run impact analysis and save the output: +For each selected node, run impact analysis and persist the result so +the Step 5d review step can pick it up automatically: ```bash -rpgkit script rpg_edit/impact.py --node-id [--node-id ...] --json | tee .rpgkit/data/rpg_edit_impact.json +rpgkit script rpg_edit/impact.py --node-id [--node-id ...] --json --save ``` -Read the output to inform the EditPlan. Do NOT present it separately — incorporate the results directly into Step 4. +The `--save` flag persists `rpg_edit_impact.json` for downstream stages; +stdout still carries the JSON for you to read. Do NOT present it +separately — incorporate the results directly into Step 4. ### Step 3.5: Visual Reconnaissance (optional, before EditPlan) @@ -197,10 +200,12 @@ in `code_changes`: - [ ] Each `description` references specific functions/classes/lines found in the code - [ ] No generic descriptions like "update styles" — cite exact CSS properties or function names -Save to `.rpgkit/data/rpg_edit_plan.json` via shell (do NOT use the Write tool for `.rpgkit/` paths): +Save the plan via the dedicated helper, which persists +`rpg_edit_plan.json` for downstream stages and prints the absolute +path on stdout. Do NOT use the Write tool for `.rpgkit/` paths: ```bash -cat > .rpgkit/data/rpg_edit_plan.json << 'PLAN_EOF' +cat << 'PLAN_EOF' | rpgkit script rpg_edit/save_plan.py PLAN_EOF ``` @@ -263,11 +268,12 @@ do **not** silently `git stash`, as that would hide their work. **Step 5b — Update RPG feature graph:** ```bash -rpgkit script rpg_edit/apply.py --plan .rpgkit/data/rpg_edit_plan.json --phase rpg-only --json +rpgkit script rpg_edit/apply.py --phase rpg-only --json ``` -This applies `feature_changes` to the RPG and saves it. The RPG now reflects the target state. -Note the `backup_timestamp` from the output — you'll need it in Step 5c and on rollback. +This applies `feature_changes` to the RPG and saves it (reading the plan +from the default home-dir location). Note the `backup_timestamp` from +the output — you'll need it in Step 5c and on rollback. **Step 5c — Apply code changes via dedicated SubAgent + refresh dep_graph + commit on the branch:** @@ -277,15 +283,15 @@ mode, and the driver script creates a single commit on the current branch (even when multiple SubAgent iterations are needed). ```bash -rpgkit script rpg_edit/code.py \ - --plan .rpgkit/data/rpg_edit_plan.json \ - --json | tee .rpgkit/data/rpg_edit_code_result.json +rpgkit script rpg_edit/code.py --json ``` Inspect the result `success` field: - `true`: code applied, single commit made on the branch (SHA in `commit_sha`). - Continue to refresh dep_graph below. + The full result JSON is on stdout; the script also persists + `rpg_edit_code_result.json` for later inspection. Continue to refresh + dep_graph below. - `false`: report `last_error` to user, do NOT refresh dep_graph, leave the rpg-edit branch for inspection. @@ -293,9 +299,8 @@ If success, refresh the dep_graph and amend the existing commit so that code + dep_graph land together: ```bash -rpgkit script rpg_edit/apply.py \ - --plan .rpgkit/data/rpg_edit_plan.json \ - --phase dep-refresh --backup-ts --json +rpgkit script rpg_edit/apply.py --phase dep-refresh \ + --backup-ts --json git add -A && git commit --amend --no-edit ``` @@ -311,13 +316,11 @@ rpgkit script smoke_test.py --json 1. **Impact review** — run targeted tests and verify affected functionality: ```bash -rpgkit script rpg_edit/review.py \ - --plan .rpgkit/data/rpg_edit_plan.json \ - --impact .rpgkit/data/rpg_edit_impact.json \ - --json +rpgkit script rpg_edit/review.py --json ``` -The review script automatically: +The review script reads the plan and impact JSON from their default +home-dir locations and automatically: - Derives test patterns from `code_changes` in the plan - Runs pytest on matching test files diff --git a/RPG-Kit/templates/commands/update_rpg.md b/RPG-Kit/templates/commands/update_rpg.md index c978c19..2cf34b2 100644 --- a/RPG-Kit/templates/commands/update_rpg.md +++ b/RPG-Kit/templates/commands/update_rpg.md @@ -22,8 +22,9 @@ This slash command is a **manual fallback** for the few cases where the automatic update didn't happen, e.g.: * You committed with `git commit --no-verify` (skipping hooks). -* The background hook errored out (network blip, LLM timeout) — check - `.rpgkit/logs/update_rpg.log`. +* The background hook errored out (network blip, LLM timeout) — run + `rpgkit version` to locate the workspace's logs directory and tail + the latest `update_rpg.log` there. * You want to force a fresh update synchronously and see the result immediately instead of waiting for the async hook. @@ -59,22 +60,17 @@ diff against, and suggest running `/rpgkit.encode` instead. Terminate. ### Step 2: Run the Update -Make sure the log directory exists, then invoke the same script the -post-commit hook uses. It creates and cleans up its own temporary -worktree internally — **you do not need to manage `git worktree` -manually**. +Invoke the same script the post-commit hook uses. It creates and cleans +up its own temporary worktree internally — **you do not need to manage +`git worktree` manually**. ```bash -mkdir -p .rpgkit/logs -rpgkit script update_graphs.py update-rpg --json \ - > .rpgkit/logs/update_rpg.log 2>&1 +rpgkit script update_graphs.py update-rpg --json ``` -The JSON result is the last `{...}` block in the log. Read it with: - -```bash -tail -n 100 .rpgkit/logs/update_rpg.log -``` +The full JSON result is printed on stdout (single `{...}` block). The +script also writes a structured log automatically; you do +not need to redirect output. ### Step 3: Display Result @@ -94,7 +90,8 @@ RPG update complete! **If `status` is `"error"`**: * Show the `error` field. -* Suggest `tail -n 200 .rpgkit/logs/update_rpg.log` for the full trace. +* Tell the user to run `rpgkit version` to locate the logs directory + and inspect `update_rpg.log` for the full trace. * Common causes: LLM API misconfigured, network failure, dirty worktree blocking `git worktree add`. @@ -107,5 +104,6 @@ Tips: automatic update failed or was skipped. - /rpgkit.encode — Run a full re-encode if the RPG seems stale or has drifted significantly from the codebase. - - .rpgkit/logs/update_rpg.log keeps the most recent run output. + - The latest `update_rpg.log` (path shown by `rpgkit version`) keeps + the most recent run output. ``` diff --git a/RPG-Kit/tests/test_dep_graph_incremental.py b/RPG-Kit/tests/test_dep_graph_incremental.py index 9bcb1de..0c8879f 100644 --- a/RPG-Kit/tests/test_dep_graph_incremental.py +++ b/RPG-Kit/tests/test_dep_graph_incremental.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for DependencyGraph incremental update API (Step 2). -The single most important invariant these tests guard is: +Core invariant under test: After any sequence of ``add_file`` / ``remove_file`` / ``update_files`` calls, the resulting DependencyGraph must be **structurally identical** diff --git a/RPG-Kit/tests/test_e2e.py b/RPG-Kit/tests/test_e2e.py index f86038c..cf0ce1c 100644 --- a/RPG-Kit/tests/test_e2e.py +++ b/RPG-Kit/tests/test_e2e.py @@ -729,10 +729,16 @@ class TestE2ECLISimulation: """Simulate CLI-like invocations end-to-end.""" def test_cli_encode_helpers(self, sample_repo, tmp_path): - """RPG_FILE path constant points to correct location.""" + """RPG_FILE path constant points to the home-dir runtime location. + + Workspace state lives under ``~/.rpgkit/workspaces//``, + so ``RPG_FILE`` resolves to ``/data/rpg.json`` rather than the + legacy ``/.rpgkit/data/rpg.json``. We just assert the trailing + path components so the test is independent of any specific hash. + """ from common.paths import RPG_FILE - assert str(RPG_FILE).endswith(os.path.join(".rpgkit", "data", "rpg.json")) + assert str(RPG_FILE).endswith(os.path.join("data", "rpg.json")) def test_cli_rpg_stats(self, encoded_rpg): """check_encode.get_rpg_stats produces valid statistics from encoded RPG data.""" diff --git a/RPG-Kit/tests/test_rpg_io.py b/RPG-Kit/tests/test_rpg_io.py new file mode 100644 index 0000000..f081054 --- /dev/null +++ b/RPG-Kit/tests/test_rpg_io.py @@ -0,0 +1,209 @@ +"""Tests for ``scripts.common.rpg_io`` (atomic write + recovery).""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +# Make sure the bundled scripts/ tree is importable. +_REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_REPO_ROOT / "scripts")) + +from common import rpg_io # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _has_git() -> bool: + from shutil import which + return which("git") is not None + + +requires_git = pytest.mark.skipif(not _has_git(), reason="git not on PATH") + + +def _make_home_layout(tmp_path: Path, hash_id: str = "abc123def456") -> Path: + """Create the ``~/.rpgkit/workspaces//`` layout for tests. + + Returns the home_dir (the dir that gets ``git init``). Caller is + responsible for git-initialising and snapshotting it. + """ + home_root = tmp_path / ".rpgkit" / "workspaces" / hash_id + (home_root / "data").mkdir(parents=True) + return home_root + + +def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]: + """Convenience wrapper for tests.""" + env = { + **os.environ, + "LC_ALL": "C", "LANG": "C", + "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "test@x", + "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "test@x", + } + return subprocess.run( + ["git", "-C", str(cwd), *args], + capture_output=True, text=True, env=env, timeout=10, check=True, + ) + + +# --------------------------------------------------------------------------- +# atomic_write_rpg +# --------------------------------------------------------------------------- + +class TestAtomicWrite: + def test_creates_file(self, tmp_path: Path) -> None: + target = tmp_path / "rpg.json" + rpg_io.atomic_write_rpg(target, {"hello": "world"}) + assert target.is_file() + assert json.loads(target.read_text()) == {"hello": "world"} + + def test_overwrites_existing(self, tmp_path: Path) -> None: + target = tmp_path / "rpg.json" + target.write_text('{"old": true}') + rpg_io.atomic_write_rpg(target, {"new": True}) + assert json.loads(target.read_text()) == {"new": True} + + def test_creates_parent_dirs(self, tmp_path: Path) -> None: + target = tmp_path / "deep" / "nested" / "rpg.json" + rpg_io.atomic_write_rpg(target, {"x": 1}) + assert target.is_file() + + def test_no_tmp_leftover_on_success(self, tmp_path: Path) -> None: + target = tmp_path / "rpg.json" + rpg_io.atomic_write_rpg(target, {"x": 1}) + tmp = target.with_suffix(".json.tmp") + assert not tmp.exists() + + def test_no_tmp_leftover_on_failure( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """If ``os.replace`` fails, the partial ``.tmp`` is cleaned up.""" + target = tmp_path / "rpg.json" + # Pre-existing valid content we shouldn't lose. + target.write_text('{"existing": "data"}') + + def boom(*_a, **_kw): + raise OSError("simulated replace failure") + monkeypatch.setattr(rpg_io.os, "replace", boom) + + with pytest.raises(OSError): + rpg_io.atomic_write_rpg(target, {"new": "would-be"}) + + # Original file untouched + assert json.loads(target.read_text()) == {"existing": "data"} + # No stray .tmp + tmp = target.with_suffix(".json.tmp") + assert not tmp.exists() + + def test_preserves_unicode(self, tmp_path: Path) -> None: + target = tmp_path / "rpg.json" + rpg_io.atomic_write_rpg(target, {"name": "测试 \u2014 ✓"}) + loaded = json.loads(target.read_text(encoding="utf-8")) + assert loaded["name"] == "测试 \u2014 ✓" + + +# --------------------------------------------------------------------------- +# safe_load_rpg — success path + propagation of FileNotFoundError +# --------------------------------------------------------------------------- + +class TestSafeLoadBasic: + def test_returns_data_on_valid_file(self, tmp_path: Path) -> None: + target = tmp_path / "rpg.json" + target.write_text(json.dumps({"ok": True})) + assert rpg_io.safe_load_rpg(target) == {"ok": True} + + def test_raises_filenotfound_when_missing(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + rpg_io.safe_load_rpg(tmp_path / "absent.json") + + def test_raises_jsondecodeerror_when_no_inner_git( + self, tmp_path: Path + ) -> None: + """Without an inner-git nearby, corruption propagates as-is.""" + target = tmp_path / "rpg.json" + target.write_text("not { valid json") + with pytest.raises(json.JSONDecodeError): + rpg_io.safe_load_rpg(target) + + +# --------------------------------------------------------------------------- +# safe_load_rpg — recovery via inner git +# --------------------------------------------------------------------------- + +@requires_git +class TestSafeLoadRecovery: + def _setup_with_history(self, tmp_path: Path) -> tuple[Path, Path, dict]: + """Build a home-layout with one good snapshot of data/rpg.json. + + Returns (home_dir, target_path, good_payload). + """ + home = _make_home_layout(tmp_path) + target = home / "data" / "rpg.json" + + # Good v1 → commit + good = {"version": 1, "nodes": [{"id": "x"}]} + rpg_io.atomic_write_rpg(target, good) + _git(home, "init", "-q", "-b", "main") + _git(home, "add", "-A") + _git(home, "commit", "-q", "-m", "v1") + return home, target, good + + def test_recovers_from_last_good_snapshot(self, tmp_path: Path) -> None: + home, target, good = self._setup_with_history(tmp_path) + + # Corrupt the file (simulate interrupted write). + target.write_text('{"version": 2, "nod') # truncated + + recovered = rpg_io.safe_load_rpg(target) + assert recovered == good + + # File on disk has been healed too. + assert json.loads(target.read_text()) == good + # No stray .tmp from the heal write. + assert not (home / "data" / "rpg.json.tmp").exists() + + def test_skips_bad_snapshots(self, tmp_path: Path) -> None: + """If recent commits are also broken, walks further back.""" + home, target, good = self._setup_with_history(tmp_path) + + # Commit an invalid JSON snapshot to bury the good one. + target.write_text('{"broken') + _git(home, "add", "-A") + _git(home, "commit", "-q", "-m", "broken commit") + + recovered = rpg_io.safe_load_rpg(target) + assert recovered == good + + def test_returns_none_when_history_has_no_valid_snapshot( + self, tmp_path: Path + ) -> None: + """No valid history → original parse error re-raised.""" + home = _make_home_layout(tmp_path) + target = home / "data" / "rpg.json" + + # First commit: already broken (pathological). + target.write_text('{not json') + _git(home, "init", "-q", "-b", "main") + _git(home, "add", "-A") + _git(home, "commit", "-q", "-m", "broken from the start") + + # Read it: corruption can't be recovered. + with pytest.raises(json.JSONDecodeError): + rpg_io.safe_load_rpg(target) + + def test_works_when_target_outside_known_layout( + self, tmp_path: Path + ) -> None: + """For paths that don't look like ``~/.rpgkit/workspaces/...``, + recovery silently no-ops and the original error re-raises.""" + target = tmp_path / "rpg.json" # not in a home-layout + target.write_text("not valid") + with pytest.raises(json.JSONDecodeError): + rpg_io.safe_load_rpg(target) diff --git a/RPG-Kit/tests/test_step3_polish.py b/RPG-Kit/tests/test_step3_polish.py index c4ae0f8..5d411c3 100644 --- a/RPG-Kit/tests/test_step3_polish.py +++ b/RPG-Kit/tests/test_step3_polish.py @@ -432,8 +432,7 @@ def test_install_post_commit_hook_writes_script(tmp_path): assert "nohup" in content assert "setsid" not in content # Atomic lock via mkdir (the only POSIX-atomic exclusive-create - # primitive available from shell). Pre-v4 used ``[ ! -f ]; touch`` - # which had a 2-second race window after a commit burst. + # primitive available from shell). assert "mkdir " in content assert "rmdir " in content # Stale-lock recovery for orphaned worker runs (>60min old). diff --git a/RPG-Kit/tests/test_storage.py b/RPG-Kit/tests/test_storage.py new file mode 100644 index 0000000..ae8eff9 --- /dev/null +++ b/RPG-Kit/tests/test_storage.py @@ -0,0 +1,324 @@ +"""Unit tests for ``rpgkit_cli._storage``.""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +# Make ``src/`` importable when running pytest directly from a clean +# checkout (no ``pip install -e .`` step). Same pattern as the other +# rpgkit_cli unit tests in this directory. +_SRC_DIR = Path(__file__).resolve().parents[1] / "src" +if str(_SRC_DIR) not in sys.path: + sys.path.insert(0, str(_SRC_DIR)) + +from rpgkit_cli import _storage # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +@pytest.fixture +def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect ``Path.home()`` to a temp dir for the duration of one test.""" + monkeypatch.setenv("HOME", str(tmp_path)) + # Some Pathlib internals also consult ``USERPROFILE`` on Windows. + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def workspace(tmp_path: Path) -> Path: + """A throwaway workspace directory.""" + ws = tmp_path / "my-workspace" + ws.mkdir() + return ws + + +# --------------------------------------------------------------------------- +# workspace_id +# --------------------------------------------------------------------------- + +class TestWorkspaceId: + def test_deterministic(self, workspace: Path) -> None: + assert _storage.workspace_id(workspace) == _storage.workspace_id(workspace) + + def test_resolves_relative(self, workspace: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(workspace.parent) + rel = Path(workspace.name) + assert _storage.workspace_id(rel) == _storage.workspace_id(workspace) + + def test_follows_symlinks(self, tmp_path: Path) -> None: + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "via-symlink" + link.symlink_to(real) + # Symlink and target must hash to the same workspace. + assert _storage.workspace_id(link) == _storage.workspace_id(real) + + def test_different_paths_differ(self, tmp_path: Path) -> None: + a = tmp_path / "a" + b = tmp_path / "b" + a.mkdir() + b.mkdir() + assert _storage.workspace_id(a) != _storage.workspace_id(b) + + def test_hash_length_is_12(self, workspace: Path) -> None: + wid = _storage.workspace_id(workspace) + assert len(wid) == 12 + assert all(c in "0123456789abcdef" for c in wid) + + +# --------------------------------------------------------------------------- +# Path helpers +# --------------------------------------------------------------------------- + +class TestPathHelpers: + def test_home_workspace_dir_under_home_root( + self, fake_home: Path, workspace: Path + ) -> None: + d = _storage.home_workspace_dir(workspace) + assert d.is_relative_to(fake_home / ".rpgkit" / "workspaces") + assert d.name == _storage.workspace_id(workspace) + + def test_data_logs_inner_git_under_home( + self, fake_home: Path, workspace: Path + ) -> None: + home = _storage.home_workspace_dir(workspace) + assert _storage.workspace_data_dir(workspace) == home / "data" + assert _storage.workspace_logs_dir(workspace) == home / "logs" + assert _storage.workspace_inner_git_dir(workspace) == home / ".git" + + def test_reports_dir_under_workspace( + self, fake_home: Path, workspace: Path + ) -> None: + """Reports stay in the workspace, not in home.""" + reports = _storage.workspace_reports_dir(workspace) + assert reports == workspace.resolve() / ".rpgkit" / "reports" + + +# --------------------------------------------------------------------------- +# find_workspace_root_from +# --------------------------------------------------------------------------- + +class TestFindWorkspaceRoot: + def _mark(self, ws: Path) -> None: + """Plant the workspace marker file.""" + (ws / ".rpgkit").mkdir(exist_ok=True) + (ws / ".rpgkit" / "config.toml").write_text("ai = 'claude'\n") + + def test_finds_at_root(self, workspace: Path) -> None: + self._mark(workspace) + assert _storage.find_workspace_root_from(workspace) == workspace.resolve() + + def test_walks_up_from_subdir(self, workspace: Path) -> None: + self._mark(workspace) + deep = workspace / "src" / "pkg" / "module" + deep.mkdir(parents=True) + assert _storage.find_workspace_root_from(deep) == workspace.resolve() + + def test_returns_none_when_outside(self, tmp_path: Path) -> None: + elsewhere = tmp_path / "no-marker" + elsewhere.mkdir() + assert _storage.find_workspace_root_from(elsewhere) is None + + def test_default_start_is_cwd( + self, workspace: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._mark(workspace) + sub = workspace / "deep" / "down" + sub.mkdir(parents=True) + monkeypatch.chdir(sub) + assert _storage.find_workspace_root_from() == workspace.resolve() + + +# --------------------------------------------------------------------------- +# .meta.toml read / write +# --------------------------------------------------------------------------- + +class TestMeta: + def test_read_returns_none_when_missing( + self, fake_home: Path, workspace: Path + ) -> None: + assert _storage.read_meta(workspace) is None + + def test_write_then_read_roundtrip( + self, fake_home: Path, workspace: Path + ) -> None: + _storage.write_meta( + workspace, + channel=_storage.CHANNEL_BUNDLE, + rpgkit_cli_version="0.1.4", + ) + data = _storage.read_meta(workspace) + assert data is not None + assert data["channel"] == "bundle" + assert data["workspace_path"] == str(workspace.resolve()) + assert data["rpgkit_cli_version_at_init"] == "0.1.4" + assert data["rpgkit_cli_version_last_seen"] == "0.1.4" + assert "created_at" in data + assert "last_seen_at" in data + + def test_write_preserves_created_at( + self, fake_home: Path, workspace: Path + ) -> None: + _storage.write_meta(workspace, channel=_storage.CHANNEL_BUNDLE) + first = _storage.read_meta(workspace) + assert first is not None + # Second write some moments later + _storage.write_meta(workspace, channel=_storage.CHANNEL_BUNDLE) + second = _storage.read_meta(workspace) + assert second is not None + assert second["created_at"] == first["created_at"] + # last_seen_at may equal or be later; either way it's a string + assert isinstance(second["last_seen_at"], str) + + def test_write_rejects_invalid_channel( + self, fake_home: Path, workspace: Path + ) -> None: + with pytest.raises(ValueError): + _storage.write_meta(workspace, channel="something-else") + + def test_atomic_write_no_tmp_leftover( + self, fake_home: Path, workspace: Path + ) -> None: + _storage.write_meta(workspace, channel=_storage.CHANNEL_BUNDLE) + meta = _storage.workspace_meta_path(workspace) + tmp = meta.with_suffix(".toml.tmp") + assert meta.is_file() + assert not tmp.exists() + + def test_handles_unparseable_meta( + self, fake_home: Path, workspace: Path + ) -> None: + # Plant a broken meta file then attempt to read. + meta = _storage.workspace_meta_path(workspace) + meta.parent.mkdir(parents=True, exist_ok=True) + meta.write_text("this is { not valid toml") + # read_meta should swallow the error and return None + assert _storage.read_meta(workspace) is None + + def test_escapes_pathological_strings( + self, fake_home: Path, tmp_path: Path + ) -> None: + """Workspace paths with backslashes / quotes / newlines round-trip.""" + # Build a workspace whose name contains characters that need + # escaping in TOML basic strings. We can't actually mkdir a + # directory with embedded newlines portably, so we exercise + # the escape function directly + a quote-bearing workspace. + ws = tmp_path / 'has "quotes" in name' + ws.mkdir() + _storage.write_meta(ws, channel=_storage.CHANNEL_BUNDLE) + data = _storage.read_meta(ws) + assert data is not None + assert data["workspace_path"] == str(ws.resolve()) + + def test_reset_resets_init_version( + self, fake_home: Path, workspace: Path + ) -> None: + """``preserve_created_at=False`` resets both timestamps AND init_version.""" + _storage.write_meta( + workspace, + channel=_storage.CHANNEL_BUNDLE, + rpgkit_cli_version="0.1.4", + ) + first = _storage.read_meta(workspace) + assert first is not None + assert first["rpgkit_cli_version_at_init"] == "0.1.4" + + _storage.write_meta( + workspace, + channel=_storage.CHANNEL_BUNDLE, + rpgkit_cli_version="0.2.0", + preserve_created_at=False, + ) + second = _storage.read_meta(workspace) + assert second is not None + # init_version should track the *current* call now, not the + # previously-recorded one. + assert second["rpgkit_cli_version_at_init"] == "0.2.0" + assert second["rpgkit_cli_version_last_seen"] == "0.2.0" + + +# --------------------------------------------------------------------------- +# ensure_workspace_storage +# --------------------------------------------------------------------------- + +class TestEnsureWorkspaceStorage: + def test_creates_layout_first_time( + self, fake_home: Path, workspace: Path + ) -> None: + home = _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + assert (home / "data").is_dir() + assert (home / "logs").is_dir() + assert _storage.workspace_meta_path(workspace).is_file() + assert _storage.workspace_reports_dir(workspace).is_dir() + + def test_idempotent(self, fake_home: Path, workspace: Path) -> None: + first = _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + second = _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + assert first == second + # No exceptions, directories still present. + assert (first / "data").is_dir() + + def test_does_not_create_inner_git( + self, fake_home: Path, workspace: Path + ) -> None: + """Inner git is owned by ``_inner_git.ensure_inner_git``, not us.""" + home = _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + assert not (home / ".git").exists() + + def test_detects_hash_collision( + self, fake_home: Path, workspace: Path + ) -> None: + """If ``.meta.toml`` records a different path, raise.""" + _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + # Tamper: rewrite meta to point at a different workspace. + meta = _storage.workspace_meta_path(workspace) + meta.write_text( + 'workspace_path = "/nowhere/else"\n' + 'channel = "bundle"\n' + 'created_at = "2024-01-01T00:00:00+00:00"\n' + 'last_seen_at = "2024-01-01T00:00:00+00:00"\n' + ) + with pytest.raises(_storage.WorkspaceMetaMismatch): + _storage.ensure_workspace_storage( + workspace, channel=_storage.CHANNEL_BUNDLE + ) + + +# --------------------------------------------------------------------------- +# resolve_data_from_cwd +# --------------------------------------------------------------------------- + +class TestResolveDataFromCwd: + def test_resolves_from_subdir( + self, fake_home: Path, workspace: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (workspace / ".rpgkit").mkdir() + (workspace / ".rpgkit" / "config.toml").write_text("") + sub = workspace / "src" + sub.mkdir() + monkeypatch.chdir(sub) + data = _storage.resolve_data_from_cwd() + assert data == _storage.workspace_data_dir(workspace) + + def test_returns_none_outside_workspace( + self, fake_home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + outside = tmp_path / "outside" + outside.mkdir() + monkeypatch.chdir(outside) + assert _storage.resolve_data_from_cwd() is None diff --git a/RPG-Kit/tests/test_workspace_unified_layout.py b/RPG-Kit/tests/test_workspace_unified_layout.py index bbd52f6..d9774b9 100644 --- a/RPG-Kit/tests/test_workspace_unified_layout.py +++ b/RPG-Kit/tests/test_workspace_unified_layout.py @@ -11,7 +11,7 @@ corrupts paths). * ``GraphQueryEngine`` handles an empty ``_code_dir_prefix`` cleanly. -These tests are deliberately decoupled from the heavier +These tests are decoupled from the heavier ``test_encoder_workspace_layout.py`` so they can be run on their own during the refactor without dragging the full encoder stack along. """ From 2cd0c646436134a4b2073281da3d1a6faa6c6ff2 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Thu, 21 May 2026 18:41:42 +0800 Subject: [PATCH 17/31] feat(cli): home-side storage helpers, walker guard, hook dispatcher src/rpgkit_cli/_storage.py (new): - workspace_id = sha256(realpath(workspace))[:12]; helpers for home dir, data/, logs/, inner-git/, reports/, meta.toml. - ensure_workspace_storage creates the layout idempotently and raises WorkspaceMetaMismatch on a hash collision against a different recorded workspace path. - find_workspace_root_from walks up from cwd, but only accepts a candidate when its home-side dir exists AND meta.workspace_path matches. Stale .rpgkit/config.toml markers (workspace deleted, moved, or renamed) are skipped instead of being silently adopted. src/rpgkit_cli/__init__.py: - Wire init/update through ensure_workspace_storage; logs and the inner-git dir are home-side, reports stay workspace-side. - 'rpgkit version' prints the resolved workspace path, data/logs dirs and inner-git snapshot count, tagged '(not created yet)' when home-side storage hasn't been bootstrapped. - New hidden 'rpgkit hook ' command. The on-disk hooks under .git/hooks/ are now 3-line stubs that exec into this command, so path resolution, logging, locking and the detached background worker live in one Python place; upgrading the CLI takes effect without reinstalling hooks. - post-commit: phase-1 'update_graphs.py sync' foreground, phase-2 'update_graphs.py update-rpg --json' detached via Popen(start_new_session=True), with an mkdir-based directory lock and 60-min stale-lock recovery. - pre-commit and post-merge: same dispatcher, sync only. src/rpgkit_cli/_inner_git.py: - Set RPGKIT_HOOK / RPGKIT_HOOK_SHA in 'rpgkit hook' so inner-git snapshot messages tag automated commits: [hook:post-commit @ a1b2c3d] update-rpg --json [hook:pre-commit @ 9f8e7d6] sync --staged-only [decoder] feature_build.py --mode step1 (manual) - _INNER_GIT_IGNORE now only excludes logs/copilot/ (MB-scale LLM session traces). Other per-stage logs are tracked so users can 'git -C ~/.rpgkit/workspaces/ log -p logs/.log' to inspect how a stage's output evolved across snapshots. - _ensure_gitignore_current rewrites the inner-git .gitignore before each commit if it has drifted from the current policy, so existing inner repos upgrade silently on the next snapshot. src/rpgkit_cli/_assets.py, entries.py: - Comment-style cleanup (drop emphasis adverbs, internal codenames, version narration). --- RPG-Kit/src/rpgkit_cli/__init__.py | 1064 ++++++++++++++++++++------ RPG-Kit/src/rpgkit_cli/_assets.py | 6 +- RPG-Kit/src/rpgkit_cli/_inner_git.py | 209 ++++- RPG-Kit/src/rpgkit_cli/_storage.py | 454 +++++++++++ RPG-Kit/src/rpgkit_cli/entries.py | 4 +- 5 files changed, 1441 insertions(+), 296 deletions(-) create mode 100644 RPG-Kit/src/rpgkit_cli/_storage.py diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 80e243b..c750d5b 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -54,6 +54,8 @@ import importlib.metadata import tomllib +from . import _storage + ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) client = httpx.Client(verify=ssl_context) @@ -305,8 +307,7 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) # # rpgkit-cli ships ``scripts/`` and ``templates/commands/`` as packaged # assets under ``rpgkit_cli/core_pack/`` so that ``rpgkit init`` works -# offline. See ``plans/01-package-bundle-and-ai-config.md`` for the -# full design. This block exposes: +# offline. This block exposes: # # _AI_TO_CLI_CMD — single source of truth for "selected AI" → # "AI CLI command to invoke from scripts". @@ -317,9 +318,12 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) # ``scripts/common/llm_client.py:_CLI_TO_AGENT`` # (the reverse mapping consumed by detect_agent_type()). # -# _SOURCE_BUNDLE / _SOURCE_LEGACY — values written to ``.rpgkit/.source`` -# so subsequent ``rpgkit update`` calls -# can honour the user's original choice. +# _SOURCE_BUNDLE / _SOURCE_LEGACY — provisioning channel; persisted as +# ``channel`` in ``~/.rpgkit/workspaces/ +# /.meta.toml`` so subsequent +# ``rpgkit update`` calls honour the +# user's original choice. Mirrors the +# constants in :mod:`rpgkit_cli._storage`. _AI_TO_CLI_CMD = { # NOTE: values below are copied verbatim from @@ -338,28 +342,57 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "amp": "amp --execute", } -_SOURCE_BUNDLE = "bundle" -_SOURCE_LEGACY = "legacy" -_SOURCE_MARKER_RELPATH = Path(".rpgkit") / ".source" -_CONFIG_RELPATH = Path(".rpgkit") / "config.toml" +# Re-exported (under the older names) to minimise churn at call sites; +# the canonical strings now live in :mod:`rpgkit_cli._storage`. +_SOURCE_BUNDLE = _storage.CHANNEL_BUNDLE +_SOURCE_LEGACY = _storage.CHANNEL_LEGACY +_CONFIG_RELPATH = _storage.WORKSPACE_MARKER_RELPATH -def _read_source_marker(project_path: Path) -> str | None: - """Return previously recorded provisioning source, or ``None``.""" - marker = project_path / _SOURCE_MARKER_RELPATH +def _current_cli_version() -> str: + """Return the installed ``rpgkit-cli`` version, or ``"dev"`` on failure. + + Used to stamp ``.meta.toml`` with the version that last touched a + given workspace. Failures (editable install, missing METADATA, + namespace package weirdness) are silently swallowed -- the version + field is purely informational. + """ try: - return marker.read_text(encoding="utf-8").strip() or None - except FileNotFoundError: - return None - except OSError: + return importlib.metadata.version("rpgkit-cli") + except importlib.metadata.PackageNotFoundError: + return "dev" + + +def _read_source_marker(project_path: Path) -> str | None: + """Return the recorded provisioning channel for ``project_path``. + + Reads ``channel`` from ``~/.rpgkit/workspaces//.meta.toml``. + Returns ``None`` when no meta file exists (fresh workspace) or the + channel field is missing. + """ + meta = _storage.read_meta(project_path) + if meta is None: return None + channel = meta.get("channel") + if isinstance(channel, str) and channel: + return channel + return None def _write_source_marker(project_path: Path, source: str) -> None: - """Persist the provisioning source so subsequent updates honour it.""" - marker = project_path / _SOURCE_MARKER_RELPATH - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text(source + "\n", encoding="utf-8") + """Persist the provisioning channel in the home-side ``.meta.toml``. + + Replaces the legacy ``workspace/.rpgkit/.source`` text file with a + structured TOML record under ``~/.rpgkit/workspaces//`` that + also carries timestamps and the version of rpgkit-cli that last + touched the workspace. See :mod:`rpgkit_cli._storage` for the + layout rationale. + """ + _storage.write_meta( + project_path, + channel=source, + rpgkit_cli_version=_current_cli_version(), + ) def _write_workspace_config(project_path: Path, selected_ai: str) -> None: @@ -398,7 +431,14 @@ def _detect_install_method() -> str: pick the right upgrade command. """ try: - exe = Path(sys.executable).resolve() + # Do not call ``.resolve()`` here. The python + # interpreter inside a uv tool venv is typically a symlink to + # the system python (``/usr/bin/python3.12`` on Linux); resolving + # it discards the ``~/.local/share/uv/tools/rpgkit-cli/`` prefix + # we depend on for installer detection. We want the path *as* + # the kernel saw it for ``sys.executable``, not the underlying + # interpreter binary it points to. + exe = Path(sys.executable) exe_str = str(exe) except Exception: return "unknown" @@ -424,8 +464,15 @@ def _detect_install_method() -> str: if "/uv/tools/" in exe_posix: return "uv" try: - # uv-receipt.json sits at the venv root one level above bin/. - if (exe.parent.parent / "uv-receipt.json").exists(): + # uv writes a receipt file at the venv root one level above bin/. + # Newer uv versions use ``uv-receipt.toml``; older releases used + # ``uv-receipt.json``. Check both so the heuristic stays robust + # across the version most users have installed at any given time. + receipt_parent = exe.parent.parent + if ( + (receipt_parent / "uv-receipt.toml").exists() + or (receipt_parent / "uv-receipt.json").exists() + ): return "uv" except Exception: pass @@ -463,6 +510,70 @@ def _upgrade_command(method: str) -> list[str] | None: return None +def _install_source() -> str: + """Identify *where* the installed ``rpgkit-cli`` came from. + + Used by the default-on auto-upgrade flow to skip dev-mode installs + (local checkout, editable) that the user is actively iterating on — + blindly running ``uv tool upgrade`` on those would either no-op + (uv complains it's not a registry release) or, worse, replace the + user's local working copy with the registry build. + + Returns: + * ``"git"`` — installed from a ``git+https://...`` URL. + Safe to auto-upgrade. + * ``"pypi"`` — installed from a PyPI release (no + ``direct_url.json`` recorded). Safe to auto-upgrade. + * ``"file"`` — installed from a local path + (``uv tool install .``). Skip auto-upgrade — the user is + developing. + * ``"editable"`` — installed with ``--editable``. Skip. + * ``"unknown"`` — couldn't determine source. Skip (conservative). + + The detection reads PEP 610's ``direct_url.json`` from the + installed distribution's metadata. We never shell out to ``uv`` + or ``pip`` for this — the local metadata is the single source of + truth and works in offline environments. + """ + try: + import importlib.metadata as _im + dist = _im.distribution("rpgkit-cli") + raw = dist.read_text("direct_url.json") + except Exception: + return "unknown" + + if raw is None: + # No direct_url.json file recorded -> installed from a PyPI + # release (PEP 610 mandates this file only for non-registry + # installs). + return "pypi" + + try: + info = json.loads(raw) + except Exception: + return "unknown" + + # Editable installs always set ``dir_info.editable: true``. + dir_info = info.get("dir_info") or {} + if isinstance(dir_info, dict) and dir_info.get("editable") is True: + return "editable" + + url = info.get("url") + if isinstance(url, str): + if url.startswith("git+") or info.get("vcs_info"): + return "git" + if url.startswith("file://"): + return "file" + + return "unknown" + + +#: Sources where auto-upgrade is safe to run by default in +#: ``rpgkit update``. Matches the values returned by +#: :func:`_install_source`. +_AUTO_UPGRADE_SOURCES: frozenset[str] = frozenset({"git", "pypi"}) + + # ── Default .gitignore template ────────────────────────────────────────── # Split into three parts so init can compose the right output depending on # project state: @@ -470,12 +581,9 @@ def _upgrade_command(method: str) -> list[str] | None: # absent (greenfield), so we don't impose Python # conventions on an existing repo that already has # its own .gitignore preferences. -# * RPGKIT_COMMON → always injected — these files MUST be ignored +# * RPGKIT_COMMON → always injected; these files must be ignored # (runtime data, machine-specific config). -# * RPGKIT_AI[ai] → always injected for the selected AI assistant — -# RPG-Kit regenerates slash command files on every -# `rpgkit init/update`, so they are build artifacts, -# not source. +# * RPGKIT_AI[ai] → always injected for the selected AI assistant. # # The Python template is a verbatim copy of GitHub's official # ``github/gitignore/Python.gitignore`` (220-line community baseline). @@ -730,10 +838,10 @@ def _upgrade_command(method: str) -> list[str] | None: """ # AI-specific slash-command directories that RPG-Kit regenerates each time -# `rpgkit init/update` runs. We deliberately scope each entry to a sub- -# directory rather than the whole agent folder so unrelated assets in -# ``.github/`` (workflows, CODEOWNERS, …) or ``.claude/`` (settings.json -# with team-shared permissions) remain trackable. +# `rpgkit init/update` runs. Each entry covers only a sub-directory of +# the agent folder so unrelated assets in ``.github/`` (workflows, +# CODEOWNERS, …) or ``.claude/`` (settings.json with team-shared +# permissions) remain trackable. _GITIGNORE_RPGKIT_AI = { "copilot": """\ # Copilot slash command definitions (regenerated by rpgkit) @@ -1096,19 +1204,19 @@ def is_git_repo(path: Path = None) -> bool: def _setup_gitignore(project_path: Path, selected_ai: str) -> None: """Materialize ``.gitignore`` with RPG-Kit's required rules. - This is the **single injection point** for all RPG-Kit gitignore + This is the single injection point for all RPG-Kit gitignore management. Other init steps (``_generate_mcp_config``, - ``_install_copilot_hooks``) MUST NOT modify ``.gitignore`` - themselves — all rules they used to inject have been folded into + ``_install_copilot_hooks``) must not modify ``.gitignore`` + themselves; all rules they used to inject have been folded into ``_GITIGNORE_RPGKIT_COMMON`` / ``_GITIGNORE_RPGKIT_AI``. - Behavior (decided by the user via interactive design review): + Behavior: * **Greenfield** — both ``.git/`` and ``.gitignore`` are absent: write Python standard template + RPG-Kit common + AI-specific rules. Gives new projects a complete, sensible default. - * **Existing repo or existing ``.gitignore``** — *do not* overwrite + * **Existing repo or existing ``.gitignore``** — do not overwrite the user's Python conventions. Only append RPG-Kit rules (deduplicated by exact line match) under a single ``# RPG-Kit ignores`` header. @@ -1377,8 +1485,7 @@ def _cleanup_legacy_codegen_persistent(project_path: Path) -> list[str]: Earlier versions of ``rpgkit init`` (pre-C4 cleanup) wrote a codegen-specific instructions file that AI agents would auto-load on every session, polluting unrelated commands (rpg_edit, encode, plain - Q&A) with codegen workflow noise. See ``plans/20260508-1-rpgkit- - optimization*.md`` § C4. + Q&A) with codegen workflow noise. This helper: @@ -1469,18 +1576,11 @@ def _generate_mcp_config( elif selected_ai == "copilot": # VS Code Copilot (1.102+): .vscode/mcp.json with top-level "servers". - # - # We deliberately do NOT write a ``sandbox`` block here. VS - # Code's MCP sandbox requires ``bubblewrap`` (bwrap) and - # ``socat`` on PATH; most Linux desktops, WSL, minimal Docker - # images and fresh macOS installs lack these, causing the - # server to crash on startup with the opaque ``Connection - # closed`` error. The only thing sandbox gained us was - # auto-approving tool confirmations — a one-click setting in - # VS Code's MCP UI ("Always allow this server") covers the - # same UX without the dependency landmine. RPG-Kit's MCP - # server is also read-only and offline, so sandbox added no - # security value. + # No ``sandbox`` block: VS Code's MCP sandbox requires bwrap + + # socat which are absent on most Linux desktops, WSL, minimal + # Docker images, and fresh macOS installs, causing the server + # to crash with "Connection closed". Tool auto-approval is + # handled by VS Code's "Always allow this server" setting. vscode_dir = project_path / ".vscode" vscode_dir.mkdir(parents=True, exist_ok=True) mcp_file = vscode_dir / "mcp.json" @@ -1859,8 +1959,8 @@ def _run_initial_encode(project_path: Path) -> bool: of lines of ``RPGParser - INFO - ...``), so instead we: * Capture stderr in a reader thread and write it verbatim to - ``.rpgkit/logs/encode.log`` — power users can ``tail -f`` it - for the full firehose. + ``~/.rpgkit/workspaces//logs/encode.log`` — power users + can ``tail -f`` it for the full firehose. * Parse a handful of phase markers off each line to drive a :class:`rich.progress.Progress` bar with a spinner + current phase + (when known) an M/N batch counter. @@ -1874,8 +1974,8 @@ def _run_initial_encode(project_path: Path) -> bool: """ encoder = project_path / ".rpgkit" / "scripts" / "rpg_encoder" / "run_encode.py" if not encoder.is_file(): - # New layout (plan 02): scripts live inside the installed wheel - # under ``rpgkit_cli/core_pack/scripts/``. Resolve the encoder + # Scripts live inside the installed wheel under + # ``rpgkit_cli/core_pack/scripts/``. Resolve the encoder # from there so the optional initial-encode kickoff works after # ``rpgkit init`` — which no longer copies scripts into the # workspace. @@ -1890,7 +1990,11 @@ def _run_initial_encode(project_path: Path) -> bool: ) return False - log_dir = project_path / ".rpgkit" / "logs" + # Keep all generated artefacts (logs/data/inner-git) in the + # per-workspace home dir under ~/.rpgkit/workspaces//. The + # workspace tree should stay clean — no .rpgkit/logs/ written here. + from . import _storage + log_dir = _storage.workspace_logs_dir(project_path) try: log_dir.mkdir(parents=True, exist_ok=True) except OSError as exc: @@ -1902,8 +2006,8 @@ def _run_initial_encode(project_path: Path) -> bool: console.print( Panel( "[cyan]Running the encoder now…[/]\n\n" - "Building [cyan].rpgkit/data/rpg.json[/] from your code via the " - "LLM. Verbose logs stream to [cyan].rpgkit/logs/encode.log[/] — " + "Building [cyan]rpg.json[/] from your code via the LLM. " + "Verbose logs stream to [cyan]" + str(log_path) + "[/] — " "`tail -f` it in another terminal for the gory details. " "Press Ctrl-C to abort; re-run later with [cyan]/rpgkit.encode[/].", title="[bold]Initial encode[/bold]", @@ -2100,8 +2204,8 @@ def _stderr_reader() -> None: console.print( Panel( "[green]Encoder finished successfully.[/]\n\n" - "The RPG graph is now available at " - "[cyan].rpgkit/data/rpg.json[/]. The post-commit hook will " + "The RPG graph is now available under your home-dir " + "workspace store ([cyan]rpg.json[/]). The post-commit hook will " "keep it in sync on every commit; the MCP tools " "([cyan]search_rpg[/], [cyan]explore_rpg[/], …) are now usable.", title="[bold green]Encode complete[/bold green]", @@ -2569,21 +2673,24 @@ def _install_git_pre_commit_hook(project_path: Path) -> bool: if hooks_dir is None: return False - marker = "# RPG-Kit: incremental RPG sync on commit" + # Level-1 hook: shell stub delegates everything to ``rpgkit hook`` + # so path resolution / logging / locking live in one Python place. + # Legacy shapes (pre-Level-1) are stripped on upgrade. + marker = "# RPG-Kit: pre-commit dispatcher" body = ( f"{marker}\n" f"{_HOOK_PATH_FALLBACK}\n" - f"rpgkit script update_graphs.py sync --staged-only 2>/dev/null || true" + f"rpgkit hook pre-commit 2>/dev/null || true" ) - # Legacy: pre-Step-3 pre-commit shipped a 2-line snippet under the - # marker below. Removed on upgrade so users don't end up running - # both the old full-sync and the new staged-only path. return _install_hook_snippet( hooks_dir, "pre-commit", "pre-commit", body, - legacy_blocks=(("# RPG-Kit: full RPG sync on commit", 2),), + legacy_blocks=( + ("# RPG-Kit: full RPG sync on commit", 2), + ("# RPG-Kit: incremental RPG sync on commit", 3), + ), ) @@ -2603,113 +2710,71 @@ def _install_git_post_merge_hook(project_path: Path) -> bool: if hooks_dir is None: return False - marker = "# RPG-Kit: incremental RPG sync after merge / pull" + # Level-1 hook: stub delegates to ``rpgkit hook post-merge``. + marker = "# RPG-Kit: post-merge dispatcher" body = ( f"{marker}\n" f"{_HOOK_PATH_FALLBACK}\n" - f"rpgkit script update_graphs.py sync 2>/dev/null || true" + f"rpgkit hook post-merge 2>/dev/null || true" + ) + return _install_hook_snippet( + hooks_dir, + "post-merge", + "post-merge", + body, + legacy_blocks=( + ("# RPG-Kit: incremental RPG sync after merge / pull", 3), + ), ) - # post-merge was introduced with the sentinel-block design already - # in mind, so no legacy migration is needed here. - return _install_hook_snippet(hooks_dir, "post-merge", "post-merge", body) def _install_git_post_commit_hook(project_path: Path) -> bool: - """Install sync + background RPG update into ``post-commit``. - - Two phases run after every commit: - - 1. **Synchronous** (foreground): ``update_graphs.py sync`` advances - ``meta.git`` to the new HEAD (~50ms). The pre-commit hook already - updated dep_graph for the staged files, so this is a cheap - hash-verify pass. - - 2. **Asynchronous** (background): ``update_graphs.py update-rpg`` - creates a git worktree for ``HEAD~1``, runs the LLM-driven - ``RPGEvolution.process_diff`` to update the feature graph, and - cleans up the worktree. Detached via ``nohup ... &`` (POSIX, - portable to macOS where ``setsid`` is absent). Output goes to - ``.rpgkit/logs/update_rpg.log``. - - Concurrency is serialised by a *directory* lock at - ``.rpgkit/logs/.update_rpg.lock`` \u2014 ``mkdir`` is the only - POSIX-atomic exclusive-create primitive available from shell, - so two commits firing in the same second reliably get one and - only one worker. Stale locks left by a SIGKILL'd previous run - are auto-recovered after 60 minutes. - - Both phases are best-effort: failures are swallowed so they never - block a commit. + """Install the Level-1 ``post-commit`` dispatcher stub. + + The on-disk hook is now a 3-line shell snippet that ``exec``s + ``rpgkit hook post-commit``. All orchestration lives in the + :func:`hook` Python command: + + * **Phase 1 (foreground)**: ``update_graphs.py sync`` advances + ``meta.git`` to the new HEAD. Output is teed into + ``~/.rpgkit/workspaces//logs/hooks.log``. + + * **Phase 2 (background)**: ``update_graphs.py update-rpg`` is + detached via ``subprocess.Popen(start_new_session=True)``. A + mkdir-based directory lock at + ``~/.rpgkit/workspaces//logs/.update_rpg.lock`` serialises + overlapping commits; locks older than 60 minutes are treated as + orphaned and removed. The worker's stdout/stderr land in + ``~/.rpgkit/workspaces//logs/update_rpg.log``. + + Both phases are best-effort: every failure path is swallowed inside + :func:`hook` so a hook misbehaviour never blocks ``git commit``. + + Legacy multi-line shell bodies from earlier releases (pre-Level-1) + are stripped on upgrade -- the ``legacy_blocks`` tuple below covers + every shape we've shipped. """ hooks_dir = _resolve_git_hooks_dir(project_path) if hooks_dir is None: return False - log_file = shlex.quote( - str((project_path / ".rpgkit" / "logs" / "update_rpg.log").resolve()) - ) - lock_file = shlex.quote( - str((project_path / ".rpgkit" / "logs" / ".update_rpg.lock").resolve()) - ) - marker = "# RPG-Kit: advance meta.git + background feature graph update" - workspace_dir = shlex.quote(str(project_path.resolve())) + marker = "# RPG-Kit: post-commit dispatcher" body = ( f"{marker}\n" f"{_HOOK_PATH_FALLBACK}\n" - # Phase 1: synchronous meta.git advance - f"rpgkit script update_graphs.py sync 2>/dev/null || true\n" - # Phase 2: background full RPG update. - # - # Lock semantics (v4): - # The lock is a *directory* created with ``mkdir`` — the only - # POSIX-atomic exclusive-create primitive available from shell. - # Two commits firing within the same second (interactive rebase, - # squash merge) reliably get serialised: exactly one wins the - # ``mkdir`` and spawns the background worker; the other no-ops. - # - # Lock recovery: - # (a) Pre-v4 installs used a *file* at this path. ``rm -f`` - # removes that file but silently no-ops on a directory - # ("Is a directory" error swallowed), so an active v4 lock - # is preserved. - # (b) Any v4 lock directory older than 60 minutes is assumed - # orphaned (worker SIGKILL'd, OOM, machine rebooted) and - # wiped. Without this, a single crashed run would silently - # disable all future background updates. - # - # Detach strategy: - # ``nohup ... &`` is POSIX-portable. We previously used - # ``setsid`` which is util-linux-only and absent from default - # macOS installs, leaving every macOS commit's phase-2 silently - # dead. - # - # env -u GIT_INDEX_FILE -u GIT_DIR: - # git sets these during hooks; if they leak into the background - # worker, ``git worktree add`` fails with cryptic index errors. - f"rm -f {lock_file} 2>/dev/null\n" - f"find {lock_file} -maxdepth 0 -mmin +60 -exec rm -rf {{}} + 2>/dev/null || true\n" - f"if mkdir {lock_file} 2>/dev/null; then\n" - f" nohup env -u GIT_INDEX_FILE -u GIT_DIR " - f'sh -c "cd {workspace_dir}; sleep 2; ' - f'rpgkit script update_graphs.py update-rpg --json >> {log_file} 2>&1; ' - f'rmdir {lock_file}" /dev/null 2>&1 &\n' - f"fi" + f"rpgkit hook post-commit 2>/dev/null || true" ) - # Legacy shapes that may exist in users' .git/hooks/post-commit from - # earlier releases. Both are stripped before the new sentinel block - # is written so the upgrade is a true replace, not an append. - # v1 (pre-Step-3 polish): 2-line sync-only snippet. - # v3 (release 0576393): 5-line snippet with the same first-line - # marker we use today plus phase-1 sync, - # phase-2 setsid background, and the - # wrapping ``if/fi`` lock check. return _install_hook_snippet( hooks_dir, "post-commit", "post-commit", body, legacy_blocks=( + # v1 (pre-Step-3): two-line sync-only snippet. ("# RPG-Kit: advance meta.git after commit", 2), + # v3 (release 0576393): five-line snippet with phase-1 sync + # + phase-2 setsid background under the same marker we used + # before Level-1. ("# RPG-Kit: advance meta.git + background feature graph update", 5), ), ) @@ -2746,7 +2811,7 @@ def _install_copilot_hooks(project_path: Path) -> None: "label": "RPG-Kit: load status", "type": "shell", # Invoke the globally-installed CLI rather than a workspace - # script copy (which no longer exists after plan 02). Same + # script copy (which no longer exists). Same # rationale as the git-hook bodies: portable command name, # auto-tracks the installed wheel's scripts. "command": "rpgkit", @@ -3132,7 +3197,7 @@ def download_and_extract_template( bundle is unavailable (editable installs, etc.). On ``--script ps`` the bundle path is rejected and the legacy path is - required (see ``plans/01-package-bundle-and-ai-config.md`` §2.5/C). + required. Returns ``project_path``. Uses the supplied :class:`StepTracker` to report progress when provided. @@ -3144,7 +3209,7 @@ def download_and_extract_template( # Bundle currently ships only POSIX-shell-flavoured scripts (today # the scripts/ tree has no bash/ or powershell/ subdirs, so the - # CI's per-shell partitioning is vestigial — see plan §2.5/C). + # CI's per-shell partitioning is vestigial. # When the user explicitly asks for PowerShell, fall back to the # legacy zip path so that future PowerShell variants in releases # keep working. The notice is emitted through the tracker (when @@ -3200,8 +3265,8 @@ def _install_from_bundle( The pipeline scripts themselves live inside the installed wheel at ``rpgkit_cli/core_pack/scripts/`` and are invoked via ``rpgkit script `` (and ``rpgkit-mcp`` for the MCP server) — they are - NOT copied to ``/.rpgkit/scripts/`` anymore. See plan 02 - for the motivation: a single source of truth per CLI install, no + NOT copied to ``/.rpgkit/scripts/`` anymore. This gives + one source of truth per CLI install, no risk of workspace/wheel drift, and no per-workspace scripts dir to keep in sync. @@ -3327,7 +3392,7 @@ def _download_and_extract_release_zip( github_token: str = None, pre: bool = False, ) -> Path: - """Original release-zip download + extract path (pre-0.1.3 behaviour). + """Release-zip download + extract path. Kept available for users that need the very latest prompts before the next CLI release, or to bypass packaging glitches. Activated via @@ -3514,14 +3579,14 @@ def _download_and_extract_release_zip( # Record provisioning source so a later ``rpgkit update`` defaults # to the same channel. Counterpart to ``_install_from_bundle`` which - # writes ``bundle``. Plan §2.5 (decision 12). + # writes ``bundle``. _write_source_marker(project_path, _SOURCE_LEGACY) # Discard the scripts copy extracted from the zip — they're not # used at runtime anymore (the workspace invokes ``rpgkit script # `` which resolves to the packaged scripts dir). Keeping # them would just be dead weight that drifts vs the installed CLI. - # Plan 02 D7: legacy zip contributes commands only. + # Legacy zip contributes commands only. legacy_scripts_dir = project_path / ".rpgkit" / "scripts" if legacy_scripts_dir.is_dir(): shutil.rmtree(legacy_scripts_dir, ignore_errors=True) @@ -3532,43 +3597,77 @@ def _download_and_extract_release_zip( def ensure_rpgkit_runtime_dirs( project_path: Path, tracker: StepTracker | None = None ) -> None: - """Pre-create RPG-Kit runtime directories under ``.rpgkit/``. - - Some early-pipeline prompts redirect stdout/stderr to - ``.rpgkit/logs/.log`` via shell ``>``, which fails with - "No such file or directory" if the parent directory does not yet exist. - The first script that calls ``setup_file_logging`` would normally - auto-create ``.rpgkit/logs/``, but that only helps stages that use - the Python logging helper — shell-redirected stages fail BEFORE the - Python process even starts. - - Creating the runtime directories upfront (during ``rpgkit init`` / - ``rpgkit update``) makes all stage prompts robust without each one - having to ``mkdir -p`` defensively. + """Pre-create RPG-Kit runtime directories under ``~/.rpgkit/``. + + The per-workspace data, logs, and inner-git snapshot repo live + under the user's home directory at ``~/.rpgkit/workspaces//`` + rather than inside the workspace. Reports stay in the workspace + (``/.rpgkit/reports/``) because they're user-facing + artefacts. + + This function is the central bootstrap for the home layout: it's + idempotent and safe to call from both ``rpgkit init`` (when the + channel was just chosen) and ``rpgkit update`` (when the channel + is read from the existing meta file). Some early-pipeline prompts + redirect stdout/stderr to ``/.log`` via shell ``>`` + before any Python code runs, so we must create the directories + upfront rather than lazily. Created (idempotent): - - ``.rpgkit/logs/`` — per-stage log files - - ``.rpgkit/data/`` — encoder / pipeline JSON artifacts - - ``.rpgkit/data/trajectory/`` — execution trajectories + - ``~/.rpgkit/workspaces//data/`` + - ``~/.rpgkit/workspaces//data/trajectory/`` + - ``~/.rpgkit/workspaces//logs/`` + - ``/.rpgkit/reports/`` + - ``~/.rpgkit/workspaces//.meta.toml`` (refreshed) + + The inner ``.git/`` directory is NOT created here; that's + the responsibility of :mod:`rpgkit_cli._inner_git`, which seeds an + initial commit with a meaningful message. """ - subdirs = ("logs", "data", "data/trajectory") - created: list[str] = [] - for sub in subdirs: - path = project_path / ".rpgkit" / sub - existed = path.exists() - try: - path.mkdir(parents=True, exist_ok=True) - if not existed: - created.append(sub) - except OSError: - # Filesystem read-only / permission issue — non-blocking. - continue + # Resolve channel: prefer what's already recorded, fall back to + # bundle. The caller (init) will explicitly call + # ``_write_source_marker`` afterwards to lock in the final value, + # so this lookup is just a sensible default for the first run. + existing_channel = _read_source_marker(project_path) + channel = existing_channel or _storage.CHANNEL_BUNDLE + + try: + home_dir = _storage.ensure_workspace_storage( + project_path, + channel=channel, + rpgkit_cli_version=_current_cli_version(), + ) + except _storage.WorkspaceMetaMismatch as exc: + # Hash collision or manual rename. Surface clearly: silently + # writing into the wrong workspace would corrupt the other + # one's data. + if tracker: + tracker.add("runtime-dirs", "Ensure ~/.rpgkit/{logs,data} directories") + tracker.error("runtime-dirs", str(exc)) + else: + console.print(f"[red]error:[/red] {exc}") + raise + except OSError as exc: + # Filesystem read-only / permission issue — non-blocking. + if tracker: + tracker.add("runtime-dirs", "Ensure ~/.rpgkit/{logs,data} directories") + tracker.error("runtime-dirs", f"could not create: {exc}") + return + + # data/trajectory is a script-specific subdir; create explicitly + # so the encoder's early stages can write into it without their + # own ``mkdir -p`` dance. + try: + (home_dir / "data" / "trajectory").mkdir(parents=True, exist_ok=True) + except OSError: + pass + if tracker: - tracker.add("runtime-dirs", "Ensure .rpgkit/{logs,data} directories") - detail = ( - f"created {', '.join(created)}" if created else "all already present" + tracker.add("runtime-dirs", "Ensure ~/.rpgkit/{logs,data} directories") + tracker.complete( + "runtime-dirs", + f"home dir at {home_dir}", ) - tracker.complete("runtime-dirs", detail) def _detect_ai_agent(project_path: Path) -> str | None: @@ -3693,8 +3792,8 @@ def init( False, "--no-rpgkit-git", help=( - "Skip initialising a private git repository inside .rpgkit/ " - "(see plan 03). Default is ON: rpgkit init seeds .rpgkit/.git " + "Skip initialising a private git repository inside .rpgkit/. " + "Default is ON: rpgkit init seeds .rpgkit/.git " "so every subsequent `rpgkit script` invocation auto-snapshots " "the workspace state, letting you `git log` / `git diff` " "between pipeline stages without extra tooling. This flag " @@ -3896,7 +3995,7 @@ def init( # --pre implies --legacy-download (bundle has no notion of # pre-release builds; the user is asking for newer prompts - # than the installed CLI release ships). Plan §2.5/D. + # than the installed CLI release ships). effective_legacy = legacy_download or pre download_and_extract_template( @@ -3918,7 +4017,7 @@ def init( # Materialise .rpgkit/config.toml with the resolved AI CLI # command. llm_client.py reads this at runtime to invoke - # the right sub-agent. Plan §3 / decision 13. + # the right sub-agent. _write_workspace_config(project_path, selected_ai) # Materialize .gitignore *before* MCP/hook generation so the @@ -4088,7 +4187,7 @@ def init( console.print(security_notice) # Pre-create runtime directories so early pipeline prompts that redirect - # to .rpgkit/logs/.log don't fail with "No such file or directory". + # to ~/.rpgkit/workspaces//logs/.log don't fail with "No such file or directory". ensure_rpgkit_runtime_dirs(project_path) steps_lines = [] @@ -4135,8 +4234,9 @@ def init( step_num += 1 steps_lines.append( - f"{step_num}. You can inspect each step's output under [cyan].rpgkit/data/[/cyan], " - f"and review detailed execution trajectories in [cyan].rpgkit/data/trajectory/[/cyan]." + f"{step_num}. You can inspect each step's output under [cyan]~/.rpgkit/workspaces//data/[/cyan], " + f"and review detailed execution trajectories under [cyan]~/.rpgkit/workspaces//data/trajectory/[/cyan]. " + f"Use [cyan]rpgkit view-graph[/cyan] from anywhere inside this workspace to open the RPG visualisation." ) step_num += 1 @@ -4150,9 +4250,9 @@ def init( # the requirement loud-and-clear here so users don't hit the silent # "rpg_unavailable" payload on their first /rpgkit.* call. steps_lines.append( - f" [yellow]Note:[/] the MCP tools query [cyan].rpgkit/data/rpg.json[/], which is " - f"created by the encoder. For existing codebases, run [cyan]/rpgkit.encode[/] " - f"once now to populate it; the post-commit hook keeps it in sync afterwards." + " [yellow]Note:[/] the MCP tools query [cyan]rpg.json[/] in the workspace's home-dir " + "store, which is created by the encoder. For existing codebases, run [cyan]/rpgkit.encode[/] " + "once now to populate it; the post-commit hook keeps it in sync afterwards." ) steps_panel = Panel( @@ -4179,8 +4279,8 @@ def init( # Initialise the private snapshot repo inside .rpgkit/. Done BEFORE # the optional initial encode so the encoder's output, if it runs, - # becomes a fresh commit on top of the [init] baseline — perfect - # diff target. Plan 03. + # becomes a fresh commit on top of the [init] baseline — a useful + # diff target. if not no_rpgkit_git: from . import _inner_git from importlib.metadata import version as _pkg_version, PackageNotFoundError @@ -4197,7 +4297,9 @@ def init( ): console.print( "[dim]Inner snapshot repo initialised at " - "[cyan].rpgkit/.git[/cyan] \u2014 `cd .rpgkit && git log` to inspect.[/dim]" + "[cyan]~/.rpgkit/workspaces//.git[/cyan] \u2014 " + "run [cyan]rpgkit version[/cyan] for the exact path " + "and a ready-to-paste `git -C` invocation.[/dim]" ) # Final step: optionally build the initial RPG by running the @@ -4268,9 +4370,18 @@ def update( False, "--pull", help=( - "Before syncing, run the appropriate upgrade command for the " - "installed CLI (uv / pipx / pip) so the latest packaged " - "assets are used. Requires network." + "Force the pre-update CLI upgrade even when auto-detection " + "would have skipped it (e.g. an editable / local-path " + "install). Mutually exclusive with --no-pull." + ), + ), + no_pull: bool = typer.Option( + False, + "--no-pull", + help=( + "Skip the default-on CLI upgrade step. Use when offline, " + "on a version-pinned CI runner, or when you've just " + "installed the CLI manually." ), ), no_rpgkit_git: bool = typer.Option( @@ -4278,8 +4389,8 @@ def update( "--no-rpgkit-git", help=( "Skip backfilling the private snapshot repo at .rpgkit/.git " - "for workspaces created before plan 03. Default is ON: if " - "the inner repo is missing, `rpgkit update` creates it and " + "for older workspaces that don't have one yet. Default is ON: " + "if the inner repo is missing, `rpgkit update` creates it and " "commits a catch-up snapshot. Pre-existing inner repos are " "never touched." ), @@ -4365,46 +4476,142 @@ def update( console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") - # --pull: self-upgrade the CLI BEFORE building the live tracker so - # that the subprocess output (and the subsequent ``os.execvp``) does - # not have to fight with rich.Live for terminal control. Plan §2.5/F. + # Pre-update CLI upgrade ------------------------------------------------- + # + # By default, ``rpgkit update`` first runs the appropriate upgrade + # command (``uv tool upgrade rpgkit-cli`` for uv installs etc.) so + # the workspace's prompts/scripts/templates always match the + # *latest* released version of the CLI. Without this, users who + # never re-install the CLI would silently drift behind upstream. # - # After a successful upgrade we re-exec the (now upgraded) rpgkit - # binary to run the rest of the update flow. This avoids the - # staleness footgun where the running Python process still has the - # old CLI code in memory while the filesystem now holds the new - # core_pack/ assets — mixing them would invite subtle bugs (new - # prompts copied by old logic). Re-exec gives us a clean separation. - if pull: - method = _detect_install_method() - cmd = _upgrade_command(method) + # We auto-upgrade only when: + # * the install method has a known upgrade command (uv, pipx, pip…) + # AND + # * the install source is remote (git URL or PyPI), meaning the + # user isn't actively developing the CLI from a local checkout. + # + # ``--no-pull`` skips this step (offline / pinned CI / freshly + # re-installed manually). ``--pull`` forces it even for sources + # we'd otherwise skip (local / editable / unknown) — useful when a + # power user really does want the registry build to overwrite + # their local install. + # + # After a successful upgrade we ``os.execvp`` the (now-upgraded) + # rpgkit binary so the rest of update runs against the freshly + # installed code + assets. Mixing old in-memory logic with new + # on-disk core_pack/ used to cause logic vs assets drift bugs. + # + # Loop guard: ``RPGKIT_UPGRADE_DONE`` is set on the re-exec'd + # process's environment. When present, this block skips the + # upgrade attempt unconditionally so an idempotent ``uv tool + # upgrade`` (which returns 0 even when there's nothing to upgrade) + # doesn't loop forever. + if pull and no_pull: + console.print( + "[red]error:[/red] --pull and --no-pull are mutually exclusive" + ) + raise typer.Exit(2) + + _UPGRADE_DONE_ENV = "RPGKIT_UPGRADE_DONE" + already_upgraded = bool(os.environ.get(_UPGRADE_DONE_ENV)) + + method = _detect_install_method() + source = _install_source() + cmd = _upgrade_command(method) + + if already_upgraded: + do_upgrade = False + skip_reason = "" # silent — internal marker, not user-visible + elif no_pull: + do_upgrade = False + skip_reason = "--no-pull" + elif pull: + do_upgrade = cmd is not None + skip_reason = ( + f"--pull but no upgrade command for install method '{method}'" + if cmd is None else "" + ) + else: + # Default-on policy: upgrade only when both the install method + # and the install source say it's safe. if cmd is None: - console.print( - f"[yellow]--pull: cannot auto-upgrade for install method " - f"'{method}'. Upgrade manually, then re-run " - f"`rpgkit update`.[/yellow]" + do_upgrade = False + skip_reason = ( + f"install method '{method}' has no auto-upgrade path " + f"(use --pull to force, or upgrade manually)" + ) + elif source not in _AUTO_UPGRADE_SOURCES: + do_upgrade = False + skip_reason = ( + f"local/dev install (source={source!r}); skipping " + f"auto-upgrade. Use --pull to force." ) else: + do_upgrade = True + skip_reason = "" + + if do_upgrade: + console.print( + f"[cyan]Upgrading rpgkit-cli via {method} (source={source})...[/cyan]" + ) + try: + rc = subprocess.call(cmd) # type: ignore[arg-type] + except FileNotFoundError: + # Upgrade tool (uv, pipx, pip) not on PATH — surface, then + # carry on with the current build. Stripping the upgrade + # is a worse user experience than failing fast here would + # be, but ``rpgkit update`` is "make my workspace match the + # installed CLI", and the installed CLI is still functional. console.print( - f"[cyan]Upgrading rpgkit-cli via {method}...[/cyan]" + f"[yellow]Upgrade tool {cmd[0]!r} not found on PATH; " + f"continuing with currently installed version.[/yellow]" ) - rc = subprocess.call(cmd) - if rc == 0: - # Re-exec without --pull so we run the upgraded logic - # against the freshly-installed core_pack. All other - # CLI flags are preserved verbatim. - new_argv = [a for a in sys.argv if a != "--pull"] - rpgkit_bin = shutil.which("rpgkit") or new_argv[0] - console.print( - "[cyan]CLI upgrade complete; re-exec'ing to apply " - "new templates...[/cyan]" - ) + rc = -1 + except Exception as exc: # noqa: BLE001 + console.print( + f"[yellow]CLI upgrade raised an unexpected error " + f"({type(exc).__name__}: {exc}); continuing with " + f"currently installed version.[/yellow]" + ) + rc = -1 + + if rc == 0: + # Re-exec the upgraded binary so the rest of update runs + # against the freshly-installed code + assets. Strip + # ``--pull`` (no longer needed) but keep every other flag. + # Set the loop-guard env var so the re-exec'd process + # doesn't immediately try to upgrade again. + new_argv = [a for a in sys.argv if a != "--pull"] + rpgkit_bin = shutil.which("rpgkit") or new_argv[0] + console.print( + "[cyan]CLI upgrade complete; re-exec'ing to apply " + "new templates...[/cyan]" + ) + try: + os.environ[_UPGRADE_DONE_ENV] = "1" os.execvp(rpgkit_bin, [rpgkit_bin, *new_argv[1:]]) - else: + except OSError as exc: + # execvp failed — fall back to running the update + # in-process with the (now-on-disk) new code. This + # mixes old in-memory logic with new assets, but + # that's strictly better than crashing here: the user + # already paid for the upgrade and wants the result. console.print( - f"[yellow]CLI upgrade exited with code {rc}; " - f"continuing with currently installed version.[/yellow]" + f"[yellow]re-exec failed ({exc}); proceeding with " + f"in-process update.[/yellow]" ) + os.environ.pop(_UPGRADE_DONE_ENV, None) + elif rc != -1: + console.print( + f"[yellow]CLI upgrade exited with code {rc}; " + f"continuing with currently installed version.[/yellow]" + ) + elif skip_reason: + # Surface the reason only when the user explicitly asked via + # --pull but we couldn't help; the default-on skip path stays + # quiet for the 99% case where nothing to do. + if pull or skip_reason == "--no-pull": + console.print(f"[dim]update: skipping CLI upgrade ({skip_reason}).[/dim]") # Build step tracker tracker = StepTracker("Update RPG-Kit Project") @@ -4473,7 +4680,7 @@ def update( _write_workspace_config(project_path, selected_ai) # Pre-create runtime directories so stage prompts that redirect - # to .rpgkit/logs/.log don't fail when the folder is + # to ~/.rpgkit/workspaces//logs/.log don't fail when the folder is # missing (e.g. user removed it, or workspace was created by an # older rpgkit init that didn't pre-create logs/). ensure_rpgkit_runtime_dirs(project_path, tracker=tracker) @@ -4568,7 +4775,7 @@ def update( f"command definitions in [cyan]{project_path}[/cyan][/dim]" ) - # Plan 03: backfill inner snapshot repo for workspaces created before + # Backfill inner snapshot repo for workspaces created before # this feature shipped. Idempotent — does nothing if .rpgkit/.git # already exists, and silently noops if --no-rpgkit-git was passed. if not no_rpgkit_git: @@ -4584,7 +4791,7 @@ def update( ): console.print( "[dim]Initialised inner snapshot repo at " - "[cyan].rpgkit/.git[/cyan] for this workspace.[/dim]" + "[cyan]~/.rpgkit/workspaces//.git[/cyan] for this workspace.[/dim]" ) @@ -4667,7 +4874,7 @@ def script( cmd = [sys.executable, str(path), *ctx.args] proc = subprocess.run(cmd, env=env) - # Plan 03: snapshot the current state of .rpgkit/ into the inner git + # Snapshot the current state of .rpgkit/ into the inner git # repo so users can `git log` / `git diff` between pipeline stages. # No-op (silently) when the script is read-only (check_*, *_validation), # the inner repo is absent (--no-rpgkit-git on init), or git is busy. @@ -4723,6 +4930,335 @@ def _resolve_script_path(relpath: str) -> Optional[Path]: return candidate +# --------------------------------------------------------------------------- +# Git-hook dispatch: ``rpgkit hook `` +# --------------------------------------------------------------------------- +# +# Python entry-point for git hooks. The on-disk hook files in +# ``.git/hooks/`` are short shell stubs that ``exec`` this command; +# path resolution, logging, locking, and detach logic live here so they +# can be updated by upgrading the CLI rather than reinstalling hooks. + +_HOOK_ENV_NAME = "RPGKIT_HOOK" +_HOOK_ENV_SHA = "RPGKIT_HOOK_SHA" +_HOOK_LOG_FILENAME = "hooks.log" +_HOOK_BACKGROUND_LOG = "update_rpg.log" +_HOOK_LOCK_DIRNAME = ".update_rpg.lock" +_HOOK_LOCK_STALE_SECONDS = 60 * 60 # 60 minutes -- matches the old shell impl + + +def _hook_log_line(log_path: Path, msg: str) -> None: + """Append a timestamped line to the hook log. Best-effort.""" + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + with open(log_path, "a", encoding="utf-8") as fh: + ts = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + fh.write(f"[{ts}] {msg}\n") + except OSError: + # Logging is observability; we never fail a hook because we + # couldn't write a line. + pass + + +def _short_head_sha(workspace: Path) -> str: + """Return ``git rev-parse --short HEAD`` for ``workspace`` or ``"?"``.""" + try: + r = subprocess.run( + ["git", "-C", str(workspace), "rev-parse", "--short", "HEAD"], + capture_output=True, text=True, timeout=5, + ) + if r.returncode == 0: + return (r.stdout or "").strip() or "?" + except (OSError, subprocess.SubprocessError): + pass + return "?" + + +def _hook_run_foreground( + workspace: Path, + log_path: Path, + env: Dict[str, str], + script_args: List[str], + label: str, +) -> int: + """Run ``rpgkit script `` and tee output into ``log_path``.""" + _hook_log_line(log_path, f"{label}: start ({' '.join(script_args)})") + try: + with open(log_path, "a", encoding="utf-8") as fh: + proc = subprocess.run( + ["rpgkit", "script", *script_args], + cwd=str(workspace), + env=env, + stdout=fh, stderr=subprocess.STDOUT, + timeout=300, + ) + _hook_log_line(log_path, f"{label}: done (exit {proc.returncode})") + return proc.returncode + except (OSError, subprocess.SubprocessError) as exc: + _hook_log_line(log_path, f"{label}: ERROR {exc!r}") + return -1 + + +def _hook_spawn_background( + workspace: Path, + home_dir: Path, + hook_log: Path, + env: Dict[str, str], +) -> None: + """Acquire a directory lock and detach ``update_graphs.py update-rpg``. + + The lock is a *directory* (``mkdir`` is the only POSIX-atomic + exclusive-create primitive); a directory older than + :data:`_HOOK_LOCK_STALE_SECONDS` is treated as orphaned (worker + killed by OOM / reboot / SIGKILL) and removed before re-trying. + """ + lock_dir = home_dir / "logs" / _HOOK_LOCK_DIRNAME + bg_log = home_dir / "logs" / _HOOK_BACKGROUND_LOG + + # Stale-lock recovery -- match the 60-minute window the shell hook used. + try: + if lock_dir.is_dir(): + age = time.time() - lock_dir.stat().st_mtime + if age > _HOOK_LOCK_STALE_SECONDS: + shutil.rmtree(lock_dir, ignore_errors=True) + _hook_log_line(hook_log, f"phase2: removed stale lock (age={age:.0f}s)") + except OSError: + pass + + # Try to acquire. + try: + lock_dir.mkdir(parents=False, exist_ok=False) + except FileExistsError: + _hook_log_line(hook_log, "phase2: skipped (another worker holds the lock)") + return + except OSError as exc: + _hook_log_line(hook_log, f"phase2: lock acquire failed: {exc!r}") + return + + # Background worker: run update-rpg, then release the lock. We + # cannot use ``Popen`` alone because nothing would ``rmdir`` the + # lock after the worker completes; a tiny ``sh -c`` wrapper does + # the cleanup deterministically. + # + # ``start_new_session=True`` is the cross-platform equivalent of + # ``nohup``/``setsid`` -- the child survives the hook's exit. + bg_log.parent.mkdir(parents=True, exist_ok=True) + lock_q = shlex.quote(str(lock_dir)) + log_q = shlex.quote(str(bg_log)) + workspace_q = shlex.quote(str(workspace)) + shell_cmd = ( + f"cd {workspace_q}; sleep 2; " + f"rpgkit script update_graphs.py update-rpg --json >> {log_q} 2>&1; " + f"rmdir {lock_q}" + ) + # Strip GIT_INDEX_FILE / GIT_DIR which git sets during hooks - + # if they leak into the worker, ``git worktree add`` fails with + # cryptic index errors. + worker_env = {k: v for k, v in env.items() if k not in ("GIT_INDEX_FILE", "GIT_DIR")} + try: + subprocess.Popen( + ["sh", "-c", shell_cmd], + cwd=str(workspace), + env=worker_env, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + _hook_log_line(hook_log, f"phase2: dispatched -> {bg_log}") + except OSError as exc: + _hook_log_line(hook_log, f"phase2: spawn failed: {exc!r}") + # Release the lock so the next commit can retry. + try: + lock_dir.rmdir() + except OSError: + pass + + +@app.command( + "hook", + hidden=True, + help="Internal git-hook dispatcher (called from .git/hooks/*).", +) +def hook(name: str = typer.Argument(..., help="Hook name: pre-commit | post-commit | post-merge")) -> None: + """Dispatch from ``.git/hooks/`` to the matching Python handler. + + Resolves the current workspace via the standard cwd-walk, attaches + a hook log under ``~/.rpgkit/workspaces//logs/hooks.log``, + and runs the per-hook orchestration. Every failure path is + swallowed (logged, never raised) so a misbehaving hook never blocks + the user's git operation. + + All ``rpgkit script`` subprocess invocations inherit two env vars: + + * ``RPGKIT_HOOK`` -- the hook name (``post-commit`` etc.) + * ``RPGKIT_HOOK_SHA`` -- short SHA of the user-facing commit + + The inner-git snapshot's commit message picks these up + (:func:`rpgkit_cli._inner_git._build_message`) so ``git log`` in the + home-side repo reads as a timeline of *user activity*, e.g.:: + + [hook:post-commit @ a1b2c3d] update-rpg + [hook:post-commit @ a1b2c3d] sync + [hook:pre-commit @ 9f8e7d6] sync --staged-only + """ + from . import _storage + + try: + ws = _storage.find_workspace_root_from(Path.cwd()) + if ws is None: + # Not in an rpgkit workspace -- silently exit success; + # the hook may be running in a repo that was provisioned + # then un-init'd, and we never want to block git. + raise typer.Exit(0) + + home_dir = _storage.home_workspace_dir(ws) + log_path = _storage.workspace_logs_dir(ws) / _HOOK_LOG_FILENAME + sha = _short_head_sha(ws) + + env = os.environ.copy() + env[_HOOK_ENV_NAME] = name + env[_HOOK_ENV_SHA] = sha + # Ensure ``rpgkit`` itself is on PATH when the hook is fired + # from a GUI editor that lacks the user's interactive shell PATH. + local_bin = str(Path.home() / ".local" / "bin") + if local_bin not in env.get("PATH", ""): + env["PATH"] = local_bin + os.pathsep + env.get("PATH", "") + + _hook_log_line(log_path, f"== {name} fired @ {sha} (ws={ws})") + + if name == "pre-commit": + _hook_run_foreground( + ws, log_path, env, + ["update_graphs.py", "sync", "--staged-only"], + "sync-staged", + ) + elif name == "post-merge": + _hook_run_foreground( + ws, log_path, env, + ["update_graphs.py", "sync"], + "sync", + ) + elif name == "post-commit": + # Phase 1: synchronous meta.git advance (fast, ~50ms). + _hook_run_foreground( + ws, log_path, env, + ["update_graphs.py", "sync"], + "phase1-sync", + ) + # Phase 2: detached background LLM-driven RPG update. + _hook_spawn_background(ws, home_dir, log_path, env) + else: + _hook_log_line(log_path, f"unknown hook name: {name!r}") + raise typer.Exit(0) + + except typer.Exit: + raise + except Exception as exc: + # Last-ditch swallow: anything reaching here means our hook + # dispatcher itself is broken, but a broken hook must not + # break ``git commit`` -- log and exit cleanly. + try: + ws = _storage.find_workspace_root_from(Path.cwd()) + if ws is not None: + _hook_log_line( + _storage.workspace_logs_dir(ws) / _HOOK_LOG_FILENAME, + f"FATAL in hook dispatcher: {exc!r}", + ) + except Exception: + pass + raise typer.Exit(0) + + raise typer.Exit(0) + + +@app.command("view-graph") +def view_graph( + no_open: bool = typer.Option( + False, + "--no-open", + help=( + "Print the path to rpg.html instead of opening it. Use " + "this in headless environments or when piping the path " + "into another tool." + ), + ), +) -> None: + """Open the workspace's RPG visualisation in the default browser. + + Resolves the workspace via cwd-walk-up (so you can run this from + any subdirectory of an rpgkit workspace) and then locates + ``rpg.html`` in priority order: + + 1. ``/.rpgkit/reports/rpg.html`` (workspace-local, when present) + 2. ``~/.rpgkit/workspaces//data/rpg.html`` (default location written by the encoder) + + The visualisation is generated by ``rpgkit script + rpg_encoder/run_encode.py`` (full encode) and refreshed by the + post-commit hook's ``update_graphs.py sync`` whenever ``rpg.json`` + changes, so it should be in sync with the current state of the + workspace. + + Exits non-zero with a clear message if (a) the cwd isn't in an + rpgkit workspace, or (b) the workspace exists but ``rpg.html`` is + missing (typically because ``/rpgkit.encode`` hasn't been run yet). + """ + ws = _storage.find_workspace_root_from() + if ws is None: + console.print( + "[red]error:[/red] not in an rpgkit workspace. " + "[dim]Run [cyan]rpgkit init[/cyan] first, or `cd` into " + "a workspace.[/dim]" + ) + raise typer.Exit(1) + + # Look in workspace reports/ first (intended permanent location) + # then home data/ (where the encoder currently writes). Both + # paths are absolute by construction. + candidates: list[Path] = [ + _storage.workspace_reports_dir(ws) / "rpg.html", + _storage.workspace_data_dir(ws) / "rpg.html", + ] + html_path: Optional[Path] = next( + (p for p in candidates if p.is_file()), None + ) + + if html_path is None: + console.print( + "[red]error:[/red] no rpg.html found for workspace " + f"[cyan]{ws}[/cyan].\n" + "[dim]Run [cyan]/rpgkit.encode[/cyan] in your AI agent to " + "build the visualisation (or [cyan]rpgkit script " + "rpg_encoder/run_encode.py[/cyan] directly).[/dim]" + ) + raise typer.Exit(2) + + if no_open: + # Plain print (no markup) so the path pipes cleanly into other + # tools: ``rpgkit view-graph --no-open | xargs open`` etc. + print(str(html_path)) + return + + # Lazy import: webbrowser is rarely needed for other CLI paths. + import webbrowser + uri = html_path.as_uri() + console.print(f"[cyan]Opening[/cyan] {html_path}") + opened = False + try: + opened = webbrowser.open(uri, new=2) + except Exception as exc: # noqa: BLE001 + console.print(f"[yellow]webbrowser.open raised {exc}[/yellow]") + if not opened: + # Headless / no browser registered. Fall through to printing + # the path so the user can copy-paste it; exit 0 because the + # request was logically successful (we found the file). + console.print( + "[yellow]Could not launch a browser. Copy this URI into " + "one manually:[/yellow]" + ) + console.print(uri) + + @app.command() def check(): """Check that all required tools are installed.""" @@ -4878,18 +5414,44 @@ def version(): info_table.add_row("Architecture", platform.machine()) info_table.add_row("OS Version", platform.version()) - # Plan 03: surface the inner-snapshot repo state when present. + # Surface the per-workspace home-side storage when + # invoked from inside an rpgkit workspace. Without this the user + # has no obvious way to find their generated artefacts / logs after + # we moved them out of the repo tree into ``~/.rpgkit/workspaces/ + # /`` — they'd have to compute the sha256 themselves. try: from . import _inner_git ws = _inner_git.find_workspace_root() if ws is not None: - count = _inner_git.snapshot_count(ws) - if count is not None: - info_table.add_row("", "") - info_table.add_row( - "Inner git", - f"{count} snapshots (cd {ws.name}/.rpgkit && git log)", - ) + home_dir = _storage.home_workspace_dir(ws) + data_dir = _storage.workspace_data_dir(ws) + logs_dir = _storage.workspace_logs_dir(ws) + # Annotate each row when the dir doesn't exist yet so the + # user doesn't mistake a computed path for a real artefact. + # Important after partial cleanup or before the first + # ``rpgkit init`` populates the home-side store — we used + # to print non-existent paths as if they were live. + def _tag(p: Path) -> str: + return str(p) if p.exists() else f"{p} [dim](not created yet)[/dim]" + + info_table.add_row("", "") + info_table.add_row("Workspace", str(ws)) + info_table.add_row("Data", _tag(data_dir)) + info_table.add_row("Logs", _tag(logs_dir)) + # Inner-git: distinguish absent (no .git dir) from empty + # (.git exists but zero commits). snapshot_count returns + # None for both, so probe has_inner_git directly. + if not home_dir.exists(): + inner_git_value = f"{home_dir} [dim](home-side dir not created — run `rpgkit init` here)[/dim]" + elif not _inner_git.has_inner_git(ws): + inner_git_value = f"{home_dir} [dim](no inner-git repo)[/dim]" + else: + count = _inner_git.snapshot_count(ws) + if count is None or count == 0: + inner_git_value = f"{home_dir} [dim](no snapshots yet)[/dim]" + else: + inner_git_value = f"{home_dir} [dim]({count} snapshots — git -C {home_dir} log)[/dim]" + info_table.add_row("Inner git", inner_git_value) except Exception: pass diff --git a/RPG-Kit/src/rpgkit_cli/_assets.py b/RPG-Kit/src/rpgkit_cli/_assets.py index 09e3528..f597867 100644 --- a/RPG-Kit/src/rpgkit_cli/_assets.py +++ b/RPG-Kit/src/rpgkit_cli/_assets.py @@ -19,16 +19,14 @@ Design notes ------------ -- We deliberately use :func:`importlib.resources.files` rather than - ``__file__`` arithmetic so that future packaging formats (zip-imports, +- Uses :func:`importlib.resources.files` rather than ``__file__`` + arithmetic so non-filesystem packaging formats (zip-imports, in-memory loaders) keep working. - The returned path is a *filesystem* path (not a Traversable) because the consumers (``shutil.copytree`` etc.) need real paths. This works for the default wheel layout; if we ever ship as a zipapp this code will need ``as_file()`` contexts. - All functions are pure / side-effect-free. No mutation of the bundle. - -Plan: ``plans/01-package-bundle-and-ai-config.md`` """ from __future__ import annotations diff --git a/RPG-Kit/src/rpgkit_cli/_inner_git.py b/RPG-Kit/src/rpgkit_cli/_inner_git.py index 0088b45..e0db506 100644 --- a/RPG-Kit/src/rpgkit_cli/_inner_git.py +++ b/RPG-Kit/src/rpgkit_cli/_inner_git.py @@ -1,11 +1,28 @@ -"""Inner-git snapshotting for ``.rpgkit/``. +"""Inner-git snapshotting for the user-home workspace directory. -Plan 03 — every successful (or failed) ``rpgkit script `` invocation -auto-commits the current state of ``.rpgkit/`` to a dedicated repo at -``.rpgkit/.git/``. Lets users `git log` / `git diff` between pipeline -stages without writing any extra tooling. +Every successful (or failed) ``rpgkit script `` invocation +auto-commits the current state of the per-workspace home directory at +``~/.rpgkit/workspaces//`` into a dedicated git repo at +``~/.rpgkit/workspaces//.git/``. This lets ``git log`` and +``git diff`` show how pipeline stages change between runs. -Design choices (see plans/03-auto-snapshot-inner-git.md): +What gets tracked: + +* ``data/`` — all encoder / pipeline output (rpg.json, dep_graph.json, + feature_*.json, …) +* ``logs/`` (except ``logs/copilot/``) — per-stage text/JSONL logs; + tracking them lets users ``git log -p logs/.log`` to debug + pipeline regressions across snapshots. +* ``.meta.toml`` — captures channel + CLI version at each snapshot; + changes only on ``rpgkit init/update``. + +What is NOT tracked (see :data:`_INNER_GIT_IGNORE` below): + +* ``logs/copilot/`` — full LLM session traces, MB-scale per run, too + noisy and too large to be useful in snapshot history. +* The inner ``.git/`` itself — git's own auto-exclusion. + +Design choices: * No global ``git config`` writes — every commit uses per-call ``-c user.email`` / ``-c user.name`` so the user's identity is @@ -25,12 +42,26 @@ from __future__ import annotations +import os import shlex import subprocess import time from pathlib import Path from typing import Optional +from . import _storage + + +# Environment variables set by ``rpgkit hook `` before invoking +# any ``rpgkit script`` calls. They flow through every subprocess so +# the snapshot commit message can record *which* git hook fired *which* +# user-facing commit instead of just naming the underlying script. +# +# Set only by :func:`rpgkit_cli.hook` -- never by manual invocations - +# so the presence of ``RPGKIT_HOOK`` is a reliable trigger-source flag. +_ENV_HOOK_NAME = "RPGKIT_HOOK" # e.g. "post-commit" / "pre-commit" +_ENV_HOOK_SHA = "RPGKIT_HOOK_SHA" # short SHA of the user-facing commit + # --------------------------------------------------------------------------- # Constants @@ -60,6 +91,22 @@ def _author_args() -> list[str]: }) +# Contents of the ``.gitignore`` written into the inner repo on init. +# +# ``logs/`` is tracked so users can run ``git log -p logs/.log`` +# to inspect how a pipeline stage's output changed between snapshots. +# +# ``logs/copilot/`` is excluded: it contains full LLM session traces +# (typically MB per session) and would dominate the snapshot history. +_INNER_GIT_IGNORE = """\ +# Managed by rpgkit-cli: do not edit. +# Logs are tracked to support `git log -p logs/.log` debugging. +# Exception: logs/copilot/ holds LLM session traces (large, not useful +# in history); inspect those files directly. +logs/copilot/ +""" + + def _basename(relpath: str) -> str: return relpath.rsplit("/", 1)[-1] @@ -89,9 +136,9 @@ def categorise_script(relpath: str) -> str: if base == "update_graphs.py": return "sync" if base == "mcp_server.py": - # Defensive: today this is on the skip list so we never commit - # an mcp_server.py invocation, but if that ever changes, tag - # it correctly rather than defaulting to ``decoder``. + # mcp_server.py is on the skip list, so this branch is unreachable + # today. Kept so adjusting the skip list still produces a correct + # tag instead of falling through to "decoder". return "mcp" return "decoder" @@ -100,26 +147,36 @@ def categorise_script(relpath: str) -> str: # Filesystem helpers # --------------------------------------------------------------------------- -def _rpgkit_dir(workspace: Path) -> Path: - return workspace / ".rpgkit" +def _inner_git_dir(workspace: Path) -> Path: + """Return the home directory used as ``git -C `` for the snapshots. + + The directory is ``~/.rpgkit/workspaces//``; the inner repo's + ``.git`` sits directly inside it. + """ + return _storage.home_workspace_dir(workspace) + + +# Backwards-compatible alias for the (now-misleading) historical name +# used in earlier docstrings. No external caller should rely on this; +# it stays only to keep grep-friendly when reading older commit +# messages and plan documents. +_rpgkit_dir = _inner_git_dir def find_workspace_root(start: Optional[Path] = None) -> Optional[Path]: - """Walk up from ``start`` (default cwd) looking for a ``.rpgkit/`` dir. + """Walk up from ``start`` (default cwd) looking for a workspace marker. - Returns the directory containing ``.rpgkit/``, or ``None`` if not found. - Used by ``rpgkit script`` to figure out which workspace's inner git - repo to snapshot into when the caller's cwd is a subdirectory. + Returns the directory containing ``.rpgkit/config.toml`` (the + workspace marker), or ``None`` if not found. Used by + ``rpgkit script`` to figure out which workspace's inner git repo to + snapshot into when the caller's cwd is a subdirectory. """ - here = (start or Path.cwd()).resolve() - for cand in [here, *here.parents]: - if (cand / ".rpgkit").is_dir(): - return cand - return None + return _storage.find_workspace_root_from(start) def has_inner_git(workspace: Path) -> bool: - return (_rpgkit_dir(workspace) / ".git").is_dir() + """True iff a ``.git`` directory exists under the workspace's home dir.""" + return (_inner_git_dir(workspace) / ".git").is_dir() def _git_available() -> bool: @@ -128,7 +185,7 @@ def _git_available() -> bool: def _run_git(workspace: Path, *args: str, check: bool = False, timeout: int = 30) -> subprocess.CompletedProcess[str]: - """Run ``git -C .rpgkit ...`` capturing stdout/stderr. + """Run ``git -C ...`` capturing stdout/stderr. ``check=False`` by default — callers inspect ``returncode`` themselves so we can silently swallow expected failures (lock, no-changes, etc.). @@ -140,7 +197,7 @@ def _run_git(workspace: Path, *args: str, check: bool = False, timeout: int = 30 """ import os as _os env = {**_os.environ, "LC_ALL": "C", "LANG": "C"} - cmd = ["git", "-C", str(_rpgkit_dir(workspace))] + list(args) + cmd = ["git", "-C", str(_inner_git_dir(workspace))] + list(args) return subprocess.run( cmd, check=check, @@ -156,18 +213,26 @@ def _run_git(workspace: Path, *args: str, check: bool = False, timeout: int = 30 # --------------------------------------------------------------------------- def ensure_inner_git(workspace: Path, *, initial_msg: Optional[str] = None) -> bool: - """Create ``.rpgkit/.git`` if missing. Returns ``True`` when newly created. + """Create ``~/.rpgkit/workspaces//.git`` if missing. - Idempotent: if the repo already exists, returns ``False`` and leaves it - untouched. + Returns ``True`` when a fresh repo was created, ``False`` when it + already existed or when setup was skipped (git missing, home dir + unavailable, …). - When a fresh repo is created, an initial commit captures the current - state of ``.rpgkit/`` (config.toml, .source, empty data/, ...). + The home dir must already exist — it's the responsibility of + ``ensure_workspace_storage`` (called from ``rpgkit init/update`` + earlier in the bootstrap) to create it. We don't create it here + because that requires picking a ``channel`` (bundle vs legacy), + which is information only the caller has. + + When a fresh repo is created we also drop a ``.gitignore`` that + excludes ``logs/`` (too noisy), then commit the current state of + ``data/`` + ``.meta.toml`` so ``git log`` has a starting point. """ - rpgkit = _rpgkit_dir(workspace) - if not rpgkit.is_dir(): - return False # nothing to track - if (rpgkit / ".git").is_dir(): + home_dir = _inner_git_dir(workspace) + if not home_dir.is_dir(): + return False # ensure_workspace_storage hasn't run yet + if (home_dir / ".git").is_dir(): return False if not _git_available(): return False @@ -179,6 +244,13 @@ def ensure_inner_git(workspace: Path, *, initial_msg: Optional[str] = None) -> b except Exception: return False + # Drop the ignore file so logs don't appear in `git status` from the + # very first commit. Best-effort; failure here doesn't block. + try: + (home_dir / ".gitignore").write_text(_INNER_GIT_IGNORE, encoding="utf-8") + except OSError: + pass + # Initial commit — even if empty, it gives `git log` a starting point. initial_msg = initial_msg or "[init] rpgkit workspace" _commit_all(workspace, initial_msg, allow_empty=True) @@ -199,6 +271,26 @@ def _has_staged_changes(workspace: Path) -> bool: _LOCK_HINTS = ("index.lock", "Another git process seems") +def _ensure_gitignore_current(workspace: Path) -> None: + """Rewrite the inner repo's ``.gitignore`` if it drifted from the + current :data:`_INNER_GIT_IGNORE`. + + Called before every commit so existing inner repos that were + initialised under an older ignore policy (e.g. the original + "ignore all of ``logs/``" rule) silently upgrade on next snapshot. + No-op when the file is already up to date. + """ + home_dir = _inner_git_dir(workspace) + gi = home_dir / ".gitignore" + try: + current = gi.read_text(encoding="utf-8") if gi.is_file() else "" + if current != _INNER_GIT_IGNORE: + gi.write_text(_INNER_GIT_IGNORE, encoding="utf-8") + except OSError: + # Best-effort: ignore policy is not critical enough to fail a commit. + pass + + def _commit_all(workspace: Path, message: str, *, allow_empty: bool = False) -> bool: """Stage everything and commit. Returns True iff a commit was created. @@ -208,6 +300,7 @@ def _commit_all(workspace: Path, message: str, *, allow_empty: bool = False) -> silently. The next successful commit will fold in any deferred changes — no data is lost. """ + _ensure_gitignore_current(workspace) for attempt in (1, 2): try: r_add = _run_git(workspace, "add", "-A") @@ -242,18 +335,56 @@ def _commit_all(workspace: Path, message: str, *, allow_empty: bool = False) -> # --------------------------------------------------------------------------- def _build_message(script_relpath: str, args: list[str], exit_code: int) -> str: + """Compose the inner-git commit message for a ``rpgkit script`` call. + + Two output shapes: + + * **Hook-triggered** (``RPGKIT_HOOK`` is set by ``rpgkit hook``):: + + [hook:post-commit @ a1b2c3d] update-rpg + [hook:pre-commit @ a1b2c3d] sync --staged-only + + Both the triggering hook name and the user-facing commit short + SHA are surfaced so ``git log`` in the inner repo reads as a + timeline of *user activity*, not a timeline of internal scripts. + + * **Manual** (no ``RPGKIT_HOOK``):: + + [decoder] feature_build.py + [encoder] rpg_encoder/run_encode.py --json + [sync] update_graphs.py update-rpg — FAILED (exit 2) + + Tagged by category (see :func:`categorise_script`) plus the full + script relpath - kept verbose so power-users running scripts by + hand can see exactly which file produced each snapshot. + """ + suffix = f" — FAILED (exit {exit_code})" if exit_code != 0 else "" + + hook = os.environ.get(_ENV_HOOK_NAME, "").strip() + if hook: + # Action = first positional arg (the script's subcommand, e.g. + # ``update-rpg``/``sync``) when present, otherwise the script + # stem. Subsequent args are appended but capped so the message + # stays one-line friendly in ``git log --oneline``. + if args: + action = args[0] + extra = " ".join(shlex.quote(a) for a in args[1:]) + extra_part = (" " + extra) if extra else "" + else: + action = _basename(script_relpath).removesuffix(".py") + extra_part = "" + if len(extra_part) > 40: + extra_part = extra_part[:37] + "..." + sha = os.environ.get(_ENV_HOOK_SHA, "").strip() + sha_part = f" @ {sha}" if sha and sha != "?" else "" + return f"[hook:{hook}{sha_part}] {action}{extra_part}{suffix}" + + # Manual / interactive path -- preserve historical format. cat = categorise_script(script_relpath) - # ``shlex.quote`` keeps args with spaces / special chars unambiguous - # in the commit log: ``[decoder] X.py 'some path'`` rather than - # ``[decoder] X.py some path``. quoted = " ".join(shlex.quote(a) for a in args) args_part = (" " + quoted).rstrip() if quoted else "" - # Cap args length so a giant args string doesn't make commit messages unreadable. if len(args_part) > 80: args_part = args_part[:77] + "..." - suffix = "" - if exit_code != 0: - suffix = f" — FAILED (exit {exit_code})" return f"[{cat}] {script_relpath}{args_part}{suffix}" diff --git a/RPG-Kit/src/rpgkit_cli/_storage.py b/RPG-Kit/src/rpgkit_cli/_storage.py new file mode 100644 index 0000000..a6d39a4 --- /dev/null +++ b/RPG-Kit/src/rpgkit_cli/_storage.py @@ -0,0 +1,454 @@ +"""Home-directory workspace storage layout for RPG-Kit. + +Replaces the legacy ``workspace/.rpgkit/{data,logs,.git}`` layout with a +centralised one rooted at ``~/.rpgkit/``: + + ~/.rpgkit/ + workspaces// + .meta.toml {workspace_path, channel, created_at, last_seen_at} + .git/ inner git snapshot repo + data/ rpg.json, dep_graph.json + logs/ *.log + +The workspace itself retains only two minimal items:: + + /.rpgkit/ + config.toml AI configuration (team-shared, committed) + reports/ user-facing reports (e.g. rpg.html) + +Workspace identity +------------------ + +Each workspace is identified by the SHA-256 hash (first 12 hex chars) of +its **resolved absolute path**. Hash collisions are detected at read +time by comparing ``workspace_path`` recorded in ``.meta.toml``; a +mismatch produces a clear error rather than silently mixing two +workspaces' data. + +Why a hash and not a path-based directory tree? A flat hash gives every +workspace a fixed-length key that's safe to use as a directory name on +all filesystems, regardless of the original path's depth or characters. + +Resolution +---------- + +The "workspace root" is discovered by walking up from the caller's +current directory looking for the marker ``.rpgkit/config.toml``. Both +the MCP server and ``rpgkit script `` use the same logic so a user +who ``cd``-s into any subdirectory of a workspace gets the right home +directory automatically. + +Public surface +-------------- + +* :func:`workspace_id` - the 12-char hash for a workspace path. +* :func:`home_workspace_dir` - ``~/.rpgkit/workspaces//``. +* :func:`workspace_data_dir`, :func:`workspace_logs_dir`, + :func:`workspace_inner_git_dir`, :func:`workspace_reports_dir` - + convenience wrappers for the four canonical subdirectories. +* :func:`ensure_workspace_storage` - idempotent: creates the home + layout and writes/updates ``.meta.toml``. +* :func:`find_workspace_root_from` - walks up from a starting path + looking for the workspace marker. +* :func:`read_meta`, :func:`write_meta` - typed accessors for + ``.meta.toml``. + +Design constraints +------------------ + +* No symlinks are created in the workspace (avoids Windows headaches + and accidental backup-tool double-counting). +* All path inputs are run through :py:meth:`Path.resolve` so symlinked + workspace roots map to a single canonical hash. +* All filesystem mutations are best-effort idempotent so re-running + ``rpgkit init`` or ``rpgkit update`` is safe. +""" +from __future__ import annotations + +import hashlib +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +try: + # Python 3.11+ + import tomllib # type: ignore[import-not-found] +except ImportError: # pragma: no cover - fallback for older Pythons + import tomli as tomllib # type: ignore[import-not-found,no-redef] + + +# --------------------------------------------------------------------------- +# Public constants +# --------------------------------------------------------------------------- + +#: Subdirectory of the user's home where rpgkit keeps all per-workspace data. +HOME_ROOT_RELPATH = Path(".rpgkit") / "workspaces" + +#: Marker file inside the workspace that identifies it as an rpgkit +#: workspace. ``rpgkit init`` writes this; cwd-walk-up looks for it. +WORKSPACE_MARKER_RELPATH = Path(".rpgkit") / "config.toml" + +#: Standard subdirectories created under each home workspace dir. +_DATA_SUBDIR = "data" +_LOGS_SUBDIR = "logs" +_INNER_GIT_SUBDIR = ".git" +_META_FILENAME = ".meta.toml" + +#: Reports directory inside the workspace (small, user-facing artefacts +#: like ``rpg.html``). +WORKSPACE_REPORTS_SUBDIR = Path(".rpgkit") / "reports" + +#: Channel values written to ``.meta.toml``. +CHANNEL_BUNDLE = "bundle" +CHANNEL_LEGACY = "legacy" +_VALID_CHANNELS = (CHANNEL_BUNDLE, CHANNEL_LEGACY) + + +# --------------------------------------------------------------------------- +# Hash + path resolution +# --------------------------------------------------------------------------- + +def _resolve(path: Path) -> Path: + """Return the canonical absolute form of ``path``. + + Symlinks are followed so that ``/home/user/proj`` and the underlying + ``/data/proj`` map to the same workspace ID. We always operate on + the resolved path internally; callers shouldn't have to think about + it. + """ + return Path(path).resolve() + + +def workspace_id(workspace_path: Path) -> str: + """Compute the 12-character workspace identifier for ``workspace_path``. + + The identifier is deterministic on a given machine: the same + resolved absolute path always yields the same hash. Different + paths (including different clones of the same git repo) yield + different hashes — this is intentional so each clone has independent + state. + """ + canonical = str(_resolve(workspace_path)) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + return digest[:12] + + +# --------------------------------------------------------------------------- +# Home-side path helpers +# --------------------------------------------------------------------------- + +def home_root() -> Path: + """Return ``~/.rpgkit/workspaces/``. + + Does not create the directory; callers should use + :func:`ensure_workspace_storage` when they need it to exist. + """ + return Path.home() / HOME_ROOT_RELPATH + + +def home_workspace_dir(workspace_path: Path) -> Path: + """Return the home directory assigned to ``workspace_path``. + + This is ``~/.rpgkit/workspaces//`` for whichever hash the path + resolves to. The directory may or may not exist on disk. + """ + return home_root() / workspace_id(workspace_path) + + +def workspace_data_dir(workspace_path: Path) -> Path: + return home_workspace_dir(workspace_path) / _DATA_SUBDIR + + +def workspace_logs_dir(workspace_path: Path) -> Path: + return home_workspace_dir(workspace_path) / _LOGS_SUBDIR + + +def workspace_inner_git_dir(workspace_path: Path) -> Path: + """Return the path of the inner-git ``.git/`` directory. + + Note: this is the GIT_DIR itself (a directory named ``.git`` sitting + inside the home workspace dir). Callers using ``git -C ...`` should + pass :func:`home_workspace_dir`; callers using ``--git-dir`` should + pass this path. + """ + return home_workspace_dir(workspace_path) / _INNER_GIT_SUBDIR + + +def workspace_meta_path(workspace_path: Path) -> Path: + return home_workspace_dir(workspace_path) / _META_FILENAME + + +def workspace_reports_dir(workspace_path: Path) -> Path: + """Return the workspace-local ``reports/`` directory. + + This is in the workspace (not home) because reports are small, + user-facing artefacts users may want to commit or browse alongside + the source code. + """ + return _resolve(workspace_path) / WORKSPACE_REPORTS_SUBDIR + + +# --------------------------------------------------------------------------- +# Marker discovery (cwd-walk-up) +# --------------------------------------------------------------------------- + +def _is_live_workspace_root(root: Path) -> bool: + """Return True iff a candidate workspace root is still live. + + A bare ``.rpgkit/config.toml`` isn't sufficient on its own: when + a user deletes (or moves) a workspace, the marker file may linger + on a parent directory whose home-side storage no longer matches. + Without this guard, :func:`find_workspace_root_from` would happily + climb into the stale parent and silently misroute reads/writes + (e.g. a freshly-stripped subdir would inherit the grandparent's + inner-git history). + + The check is intentionally minimal and side-effect-free: + + 1. ``~/.rpgkit/workspaces//`` exists — guards against + "home-side pruned" scenarios. + 2. If ``.meta.toml`` exists, ``workspace_path`` matches ``root`` — + guards against "directory was moved/renamed" scenarios where + the stale marker still points at the old absolute path. + """ + if not home_workspace_dir(root).is_dir(): + return False + meta = read_meta(root) + if meta is not None: + recorded = meta.get("workspace_path") + if isinstance(recorded, str) and Path(recorded) != _resolve(root): + return False + return True + + +def find_workspace_root_from(start: Optional[Path] = None) -> Optional[Path]: + """Walk up from ``start`` (default: cwd) looking for an rpgkit workspace. + + A directory qualifies as a workspace if it contains + ``.rpgkit/config.toml`` (see :data:`WORKSPACE_MARKER_RELPATH`) + **and** passes :func:`_is_live_workspace_root` — i.e. its home-side + storage still exists and the recorded path matches. Stale markers + on parent directories are skipped, so the walker continues climbing + rather than misrouting into a different workspace's state. + + Returns the **resolved** path of the workspace root, or ``None`` + when no live marker is found before reaching the filesystem root. + """ + cur = _resolve(start if start is not None else Path.cwd()) + while True: + if (cur / WORKSPACE_MARKER_RELPATH).is_file() and _is_live_workspace_root(cur): + return cur + if cur.parent == cur: # reached / (POSIX) or drive root (Windows) + return None + cur = cur.parent + + +# --------------------------------------------------------------------------- +# Metadata +# --------------------------------------------------------------------------- + +def _utc_now_iso() -> str: + """ISO-8601 timestamp in UTC, second precision (no microseconds).""" + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def _toml_escape(value: str) -> str: + """Minimal TOML basic-string escape (sufficient for our values). + + Handles the chars TOML's basic-string syntax actually forbids or + requires escaping: backslash, double quote, and the control chars + that are common in pathological inputs (newline, carriage return, + tab). Other control chars (NUL, vertical tab, etc.) would also be + invalid but are vanishingly unlikely in our inputs (paths + + version strings); we accept the small remaining risk rather than + pull in a full TOML writer dependency. + """ + return ( + value + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + +def read_meta(workspace_path: Path) -> Optional[Dict[str, Any]]: + """Read ``.meta.toml`` for ``workspace_path`` or return ``None``. + + Returns ``None`` if the file doesn't exist or fails to parse. + A separate parse-failure return value isn't useful in practice - + every caller treats both cases as "no metadata yet". + """ + meta_file = workspace_meta_path(workspace_path) + if not meta_file.is_file(): + return None + try: + with open(meta_file, "rb") as f: + return tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError): + return None + + +def write_meta( + workspace_path: Path, + *, + channel: str, + rpgkit_cli_version: Optional[str] = None, + preserve_created_at: bool = True, +) -> None: + """Atomically write the workspace's ``.meta.toml``. + + Args: + workspace_path: The workspace directory (resolved internally). + channel: ``"bundle"`` or ``"legacy"`` -- which provisioning + channel was used. + rpgkit_cli_version: The installed rpgkit-cli version at write + time. Stored as ``rpgkit_cli_version_at_init`` (only on + first write) and ``rpgkit_cli_version_last_seen`` (every + write). + preserve_created_at: When True (the default), keep the original + ``created_at`` from any existing meta file; otherwise + overwrite with ``utc_now()``. + + Raises: + ValueError: if ``channel`` is not a recognised value. + OSError: if the file can't be written. + """ + if channel not in _VALID_CHANNELS: + raise ValueError( + f"channel must be one of {_VALID_CHANNELS!r}, got {channel!r}" + ) + + resolved = _resolve(workspace_path) + meta_file = workspace_meta_path(workspace_path) + meta_file.parent.mkdir(parents=True, exist_ok=True) + + existing = read_meta(workspace_path) or {} + now = _utc_now_iso() + if preserve_created_at: + created_at = existing.get("created_at", now) + # On preserve, also carry forward the version recorded at init + # so re-running ``rpgkit update`` doesn't blow away that history. + init_version = existing.get( + "rpgkit_cli_version_at_init", rpgkit_cli_version or "" + ) + else: + # "Reset" semantics: created_at and init_version both refresh + # to the values supplied in this call. + created_at = now + init_version = rpgkit_cli_version or "" + + # Serialise by hand - tiny + avoids a TOML writer dep. + lines = [ + "# RPG-Kit per-workspace state. Managed by `rpgkit init/update`.", + "# Do not commit; recreated automatically if missing.", + "", + f'workspace_path = "{_toml_escape(str(resolved))}"', + f'channel = "{channel}"', + f'created_at = "{created_at}"', + f'last_seen_at = "{now}"', + ] + if init_version: + lines.append(f'rpgkit_cli_version_at_init = "{_toml_escape(init_version)}"') + if rpgkit_cli_version: + lines.append( + f'rpgkit_cli_version_last_seen = "{_toml_escape(rpgkit_cli_version)}"' + ) + payload = "\n".join(lines) + "\n" + + # Atomic write: .tmp + os.replace + tmp = meta_file.with_suffix(".toml.tmp") + try: + tmp.write_text(payload, encoding="utf-8") + os.replace(tmp, meta_file) + except Exception: + if tmp.exists(): + try: + tmp.unlink() + except OSError: + pass + raise + + +# --------------------------------------------------------------------------- +# Layout bootstrap + integrity check +# --------------------------------------------------------------------------- + +class WorkspaceMetaMismatch(RuntimeError): + """Raised when an existing ``.meta.toml`` points at a different path. + + This indicates either a hash collision (statistically very rare for + a 48-bit truncated hash on a single machine, but possible) or a + user manually moving directories under ``~/.rpgkit/``. We never + silently mix two workspaces' data; the user must investigate. + """ + + +def ensure_workspace_storage( + workspace_path: Path, + *, + channel: str, + rpgkit_cli_version: Optional[str] = None, +) -> Path: + """Create the home layout for ``workspace_path`` (idempotent). + + Creates:: + + ~/.rpgkit/workspaces// + data/ + logs/ + + Writes ``.meta.toml`` capturing the workspace path, channel, and + timestamps. If an existing ``.meta.toml`` records a *different* + workspace path (hash collision or manual rename), raises + :class:`WorkspaceMetaMismatch` -- callers must surface this clearly + rather than overwriting another workspace's data. + + The inner ``.git/`` directory is NOT created here; that's the + responsibility of :mod:`rpgkit_cli._inner_git`, which knows how to + seed an initial commit message. + + Returns: + The home workspace directory (``~/.rpgkit/workspaces//``). + """ + resolved = _resolve(workspace_path) + home_dir = home_workspace_dir(resolved) + + # Hash-collision / rename guard. + existing = read_meta(resolved) + if existing is not None: + recorded = existing.get("workspace_path") + if isinstance(recorded, str) and Path(recorded).resolve() != resolved: + raise WorkspaceMetaMismatch( + f"Workspace hash collision at {home_dir}: meta points to " + f"{recorded!r} but caller passed {str(resolved)!r}. " + f"Resolve manually (e.g., move or delete the offending " + f"directory) before retrying." + ) + + (home_dir / _DATA_SUBDIR).mkdir(parents=True, exist_ok=True) + (home_dir / _LOGS_SUBDIR).mkdir(parents=True, exist_ok=True) + workspace_reports_dir(resolved).mkdir(parents=True, exist_ok=True) + + write_meta(resolved, channel=channel, rpgkit_cli_version=rpgkit_cli_version) + return home_dir + + +# --------------------------------------------------------------------------- +# Convenience: resolve from cwd in one step +# --------------------------------------------------------------------------- + +def resolve_data_from_cwd(start: Optional[Path] = None) -> Optional[Path]: + """Find the workspace from ``start`` and return its data directory. + + Convenience for scripts that just want the canonical + ``data/rpg.json`` location without manually chaining + :func:`find_workspace_root_from` and :func:`workspace_data_dir`. + Returns ``None`` if no workspace is found. + """ + root = find_workspace_root_from(start) + if root is None: + return None + return workspace_data_dir(root) diff --git a/RPG-Kit/src/rpgkit_cli/entries.py b/RPG-Kit/src/rpgkit_cli/entries.py index 152e04e..c851d7e 100644 --- a/RPG-Kit/src/rpgkit_cli/entries.py +++ b/RPG-Kit/src/rpgkit_cli/entries.py @@ -6,8 +6,8 @@ ``sys.path`` so that the bundled ``scripts/`` directory is importable, then hands off to ``mcp_server.main()``. -This module is deliberately tiny and stdout-silent — MCP uses stdio as -its transport, so writing anything to stdout from import-time code would +This module stays small and stdout-silent because MCP uses stdio as +its transport: anything written to stdout from import-time code would corrupt the JSON-RPC stream. All diagnostics go to stderr. """ From bd03c1f53d50236808de1f664b0cda06447160ce Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 09:40:46 +0800 Subject: [PATCH 18/31] fix(rpgkit/_inner_git): strip inherited GIT_* env in inner-git calls When the outer repo's git commit runs a hook, git exports GIT_INDEX_FILE pointing at the outer .git/index.lock. If we leak that into the inner-git subprocess, the inner-git add -A writes entries (from $HOME/.rpgkit/...) into the outer index.lock, corrupting the outer commit ("error: invalid object ... Error building trees"). Strip GIT_INDEX_FILE / GIT_DIR / GIT_WORK_TREE / GIT_OBJECT_DIRECTORY before invoking the inner-git so the two repos stay isolated. --- RPG-Kit/src/rpgkit_cli/_inner_git.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/RPG-Kit/src/rpgkit_cli/_inner_git.py b/RPG-Kit/src/rpgkit_cli/_inner_git.py index e0db506..02f1810 100644 --- a/RPG-Kit/src/rpgkit_cli/_inner_git.py +++ b/RPG-Kit/src/rpgkit_cli/_inner_git.py @@ -197,6 +197,12 @@ def _run_git(workspace: Path, *args: str, check: bool = False, timeout: int = 30 """ import os as _os env = {**_os.environ, "LC_ALL": "C", "LANG": "C"} + # Strip inherited git env vars: a foreground hook caller may have set + # GIT_INDEX_FILE / GIT_DIR / GIT_WORK_TREE pointing at the outer repo. + # If we leak those into the inner-git call the outer repo's index gets + # corrupted (entries from $HOME/.rpgkit get written into the outer index.lock). + for _v in ("GIT_INDEX_FILE", "GIT_DIR", "GIT_WORK_TREE", "GIT_OBJECT_DIRECTORY"): + env.pop(_v, None) cmd = ["git", "-C", str(_inner_git_dir(workspace))] + list(args) return subprocess.run( cmd, From 5c214708095fdd5ff68203214d4ea2a57fc8a05c Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 09:40:46 +0800 Subject: [PATCH 19/31] fix(rpgkit/scripts): print only file basename to avoid path leakage Several scripts logged absolute paths like '/home/user/proj/data/X.json' on success. When run via 'rpgkit script' under an LLM agent, those paths reveal the workspace layout (and home-side .rpgkit/ paths) to the agent, which then tries to access them and fails. Switch all 'saved to: {path}' messages to use path.name so stdout stays workspace-independent. Files touched: build_data_flow, build_skeleton, design_base_classes, design_interfaces, feature_spec_to_json, plan_tasks, summary_skeleton. --- RPG-Kit/scripts/build_data_flow.py | 8 ++++---- RPG-Kit/scripts/build_skeleton.py | 10 +++++----- RPG-Kit/scripts/design_base_classes.py | 4 ++-- RPG-Kit/scripts/design_interfaces.py | 4 ++-- RPG-Kit/scripts/feature_spec_to_json.py | 7 ++++--- RPG-Kit/scripts/plan_tasks.py | 2 +- RPG-Kit/scripts/summary_skeleton.py | 2 +- 7 files changed, 19 insertions(+), 18 deletions(-) diff --git a/RPG-Kit/scripts/build_data_flow.py b/RPG-Kit/scripts/build_data_flow.py index 1294e81..1c4fd6b 100644 --- a/RPG-Kit/scripts/build_data_flow.py +++ b/RPG-Kit/scripts/build_data_flow.py @@ -106,9 +106,9 @@ def update_rpg_with_data_flow(data_flow_data: Dict[str, Any], rpg_path: Path): svc.save(rpg_path) if added > 0: - print(f"[OK] Added {added} data flow edges to: {rpg_path}") + print(f"[OK] Added {added} data flow edges to: {rpg_path.name}") else: - print(f"No new data flow edges to add to: {rpg_path}") + print(f"No new data flow edges to add to: {rpg_path.name}") # ============================================================================ @@ -354,7 +354,7 @@ def main(): logger.info(f"[OK] Data flow saved to: {output_path}") builder.print_summary(result) - print(f"\n[OK] Data flow saved to: {output_path}") + print(f"\n[OK] Data flow saved to: {output_path.name}") # Add data flow edges to repo_rpg.json update_rpg_with_data_flow(result, Path(args.repo_rpg)) @@ -370,7 +370,7 @@ def main(): "components": len(result.get("components", [])), "edges": len(result.get("data_flow", [])) }) - print(f"[OK] Trajectory saved to: {trajectory.trajectory_file}") + print(f"[OK] Trajectory saved to: {trajectory.trajectory_file.name}") return 0 diff --git a/RPG-Kit/scripts/build_skeleton.py b/RPG-Kit/scripts/build_skeleton.py index 0cf9b71..30df76c 100644 --- a/RPG-Kit/scripts/build_skeleton.py +++ b/RPG-Kit/scripts/build_skeleton.py @@ -200,7 +200,7 @@ def build(self, input_data: Dict[str, Any]) -> Dict[str, Any]: # Save updated RPG (with directory assignments) self.rpg.save_json(str(REPO_RPG_FILE), indent=2) - print(f" [OK] Updated RPG saved to: {REPO_RPG_FILE}") + print(f" [OK] Updated RPG saved to: {REPO_RPG_FILE.name}") self._print_summary() @@ -226,7 +226,7 @@ def _step1_build_rpg(self) -> bool: print(f" - Total nodes: {stats['total_nodes']}") print(f" - Node types: {dict(stats['node_types'])}") print(f" - Level distribution: {dict(stats['levels'])}") - print(f" [OK] RPG saved to: {REPO_RPG_FILE}") + print(f" [OK] RPG saved to: {REPO_RPG_FILE.name}") return True @@ -688,11 +688,11 @@ def main(): json.dump(result, f, indent=2, ensure_ascii=False) logger.info(f"[OK] Skeleton saved to: {output_path}") - print(f"\n[OK] Skeleton saved to: {output_path}") + print(f"\n[OK] Skeleton saved to: {output_path.name}") # Save RPG as well if REPO_RPG_FILE.exists(): - print(f"[OK] RPG saved to: {REPO_RPG_FILE}") + print(f"[OK] RPG saved to: {REPO_RPG_FILE.name}") # Mark trajectory as complete if trajectory: @@ -701,7 +701,7 @@ def main(): "assigned_features": builder.stats["assigned_features"], "total_files": builder.stats["total_files"] }) - print(f"[OK] Trajectory saved to: {trajectory.trajectory_file}") + print(f"[OK] Trajectory saved to: {trajectory.trajectory_file.name}") return 0 diff --git a/RPG-Kit/scripts/design_base_classes.py b/RPG-Kit/scripts/design_base_classes.py index 37f1d4a..b6f6ad2 100644 --- a/RPG-Kit/scripts/design_base_classes.py +++ b/RPG-Kit/scripts/design_base_classes.py @@ -521,7 +521,7 @@ def main(): logger.info(f"[OK] Base classes saved to: {output_path}") designer.print_summary(result) - print(f"\n[OK] Base classes saved to: {output_path}") + print(f"\n[OK] Base classes saved to: {output_path.name}") # Update RPG with base classes if result.get("success", True): @@ -540,7 +540,7 @@ def main(): "data_structure_files": len(result.get("data_structures", [])), "data_structure_names": result.get("data_structure_names", []), }) - print(f"[OK] Trajectory saved to: {trajectory.trajectory_file}") + print(f"[OK] Trajectory saved to: {trajectory.trajectory_file.name}") return 0 diff --git a/RPG-Kit/scripts/design_interfaces.py b/RPG-Kit/scripts/design_interfaces.py index 5de9201..a1b70bc 100644 --- a/RPG-Kit/scripts/design_interfaces.py +++ b/RPG-Kit/scripts/design_interfaces.py @@ -1208,7 +1208,7 @@ def main(): logger.info(f"[OK] Interfaces saved to: {output_path}") designer.print_summary(result) - print(f"\n[OK] Interfaces saved to: {output_path}") + print(f"\n[OK] Interfaces saved to: {output_path.name}") # RPG update is now handled inside InterfaceDesigner.build() via InterfacesStore @@ -1233,7 +1233,7 @@ def main(): "invocation_edges": len(enhanced_data_flow.get("invocation_edges", [])), "reference_edges": len(enhanced_data_flow.get("reference_edges", [])) }) - print(f"[OK] Trajectory saved to: {trajectory.trajectory_file}") + print(f"[OK] Trajectory saved to: {trajectory.trajectory_file.name}") return 0 diff --git a/RPG-Kit/scripts/feature_spec_to_json.py b/RPG-Kit/scripts/feature_spec_to_json.py index 9255435..733bb35 100644 --- a/RPG-Kit/scripts/feature_spec_to_json.py +++ b/RPG-Kit/scripts/feature_spec_to_json.py @@ -406,7 +406,7 @@ def main(): include_evidence = not args.no_evidence - print(f"Parsing feature specification from: {input_dir}") + print(f"Parsing feature specification from: {input_dir.name}") print(f"Include evidence: {include_evidence}") try: @@ -417,8 +417,9 @@ def main(): with open(output_file, "w", encoding="utf-8") as f: json.dump(spec, f, indent=2, ensure_ascii=False) - # Print summary - print(f"\nOutput written to: {output_file}") + # Print summary — use only the file name so stdout stays + # workspace-independent; the agent cannot access home-side paths. + print(f"\nOutput written to: {output_file.name}") print(f" - Repository: {spec.get('repository_name', 'N/A')}") print(f" - Background items: {len(spec.get('background_and_overview', []))}") print(f" - NFR items: {len(spec.get('non_functional_requirements', []))}") diff --git a/RPG-Kit/scripts/plan_tasks.py b/RPG-Kit/scripts/plan_tasks.py index 1d3a891..d205c06 100644 --- a/RPG-Kit/scripts/plan_tasks.py +++ b/RPG-Kit/scripts/plan_tasks.py @@ -1599,7 +1599,7 @@ def main(): with open(args.output, 'w', encoding='utf-8') as f: json.dump(result, f, indent=2) - print(f"\n [OK] Tasks saved to: {args.output}") + print(f"\n [OK] Tasks saved to: {args.output.name}") # Complete trajectory if trajectory: diff --git a/RPG-Kit/scripts/summary_skeleton.py b/RPG-Kit/scripts/summary_skeleton.py index ddb9ad4..34899b5 100644 --- a/RPG-Kit/scripts/summary_skeleton.py +++ b/RPG-Kit/scripts/summary_skeleton.py @@ -438,7 +438,7 @@ def main() -> int: with open(output_path, "w", encoding="utf-8") as f: generate_summary(skeleton_data, use_color=False, output=f) - print(f"Summary saved to: {output_path}") + print(f"Summary saved to: {output_path.name}") return 0 From 660207f9f7d81148ea5e1066aef8cfeac3376604 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 09:40:47 +0800 Subject: [PATCH 20/31] feat(rpgkit/cli): tee 'rpgkit script' stdout into logs/.log Per-stage script invocations now persist their stdout into the workspace logs directory (resolved via _storage.workspace_logs_dir). If the home-side dir doesn't exist yet (rpgkit init hasn't run), fall back to the previous behaviour (no log, direct passthrough). --- RPG-Kit/src/rpgkit_cli/__init__.py | 38 ++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index c750d5b..488afb6 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -4871,8 +4871,42 @@ def script( env = os.environ.copy() env.setdefault("PYTHONDONTWRITEBYTECODE", "1") - cmd = [sys.executable, str(path), *ctx.args] - proc = subprocess.run(cmd, env=env) + # Tee stdout to a per-stage log file so the workspace has a persistent + # record of every script invocation. The log path is resolved from + # _storage at run time; if the home-side dir doesn't exist yet (e.g. + # rpgkit init hasn't run), skip silently — no log is better than + # crashing. + log_path: Optional[Path] = None + from . import _inner_git as _ig + ws_root = _ig.find_workspace_root() + if ws_root is not None: + from . import _storage + logs_dir = _storage.workspace_logs_dir(ws_root) + if logs_dir.is_dir(): + script_stem = path.stem # e.g. "feature_build" + log_path = logs_dir / f"{script_stem}.log" + + if log_path is not None: + log_fh = open(log_path, "a", encoding="utf-8") + cmd = [sys.executable, str(path), *ctx.args] + proc = subprocess.run( + cmd, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + ) + # Write captured output to both terminal and log file. + output = proc.stdout or b"" + sys.stdout.buffer.write(output) + sys.stdout.buffer.flush() + try: + log_fh.write(output.decode("utf-8", errors="replace")) + log_fh.flush() + except OSError: + pass + finally: + log_fh.close() + else: + cmd = [sys.executable, str(path), *ctx.args] + proc = subprocess.run(cmd, env=env) # Snapshot the current state of .rpgkit/ into the inner git # repo so users can `git log` / `git diff` between pipeline stages. From a6af6d82a1392ac752d9291c269f4231f52a8eb3 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 10:42:49 +0800 Subject: [PATCH 21/31] fix(llm_client): tolerate session trace outside workspace root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude writes session traces under $HOME/.claude/projects//sessions/ which is outside the rpgkit workspace. The metadata bookkeeping line called Path.relative_to(workspace_root) and raised ValueError, aborting the whole LLM call (and all retries) even after a successful response. Catch ValueError and fall back to the absolute path — session_trace is informational metadata and must never break the LLM call. --- RPG-Kit/scripts/common/llm_client.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/RPG-Kit/scripts/common/llm_client.py b/RPG-Kit/scripts/common/llm_client.py index f610651..6a875d8 100644 --- a/RPG-Kit/scripts/common/llm_client.py +++ b/RPG-Kit/scripts/common/llm_client.py @@ -436,9 +436,15 @@ def generate( call_record.success = response is not None call_record.error = error if not response else None if captured_path: - call_record.metadata["session_trace"] = str( - captured_path.relative_to(self._INFERRED_PROJECT_DIR) - ) + try: + rel = captured_path.relative_to(self._INFERRED_PROJECT_DIR) + call_record.metadata["session_trace"] = str(rel) + except ValueError: + # captured_path lives outside the workspace (e.g. Claude + # writes traces under ~/.claude/projects//sessions/). + # session_trace is purely informational — never let a + # bookkeeping error abort the LLM call. + call_record.metadata["session_trace"] = str(captured_path) # Store in history self._call_history.append(call_record) From 782ce1cca9a5824b5942bb30629ae5eaf914a7f1 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 11:33:11 +0800 Subject: [PATCH 22/31] fix(hooks): retire pre-commit; rely on post-commit + post-merge only The pre-commit hook ran 'update_graphs.py sync --staged-only' and was followed ~1 sec later by the full post-commit sync that overwrote its output. Net effect: extra latency on every git commit and noisy [hook:pre-commit] entries in inner-git history, with no observable benefit (its writes never reach user's commit because all RPG state lives home-side). Drop the install path; replace with idempotent _uninstall_git_pre_commit_hook so existing workspaces are cleaned on the next 'rpgkit init' / 'rpgkit update'. Keep 'rpgkit hook pre-commit' dispatcher branch as a deliberate no-op for backward compat with old hook files that haven't been stripped yet. Also update MCP rpg-tools description ("pre-commit hook" -> "post-commit hook"). --- RPG-Kit/scripts/mcp_server.py | 2 +- RPG-Kit/src/rpgkit_cli/__init__.py | 107 +++++++++++++++-------------- 2 files changed, 58 insertions(+), 51 deletions(-) diff --git a/RPG-Kit/scripts/mcp_server.py b/RPG-Kit/scripts/mcp_server.py index e6f3d05..255f1ed 100644 --- a/RPG-Kit/scripts/mcp_server.py +++ b/RPG-Kit/scripts/mcp_server.py @@ -193,7 +193,7 @@ def _unavailable_reason() -> str: "Program Graph (RPG) for the current workspace \u2014 a " "pre-computed, queryable index of the codebase built by " "`/rpgkit.encode` and kept in sync with HEAD by a " - "pre-commit hook.\n\n" + "post-commit hook.\n\n" "What the RPG knows about this repository:\n" " \u2022 The feature hierarchy: functional areas \u2192 " "feature groups \u2192 individual features, each linked to " diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 488afb6..0140ca4 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -2652,46 +2652,48 @@ def _install_hook_snippet( return True -def _install_git_pre_commit_hook(project_path: Path) -> bool: - """Install the RPG incremental-sync command into ``pre-commit``. - - Returns ``True`` when the hook is active on disk, ``False`` only - when no git checkout was found at all. - - The hook passes ``--staged-only`` so only files the user - ``git add``'d contribute to the diff — working-tree-but-not-staged - changes are out of scope for the imminent commit. - - The hook invokes the globally-installed ``rpgkit`` CLI rather than - a workspace-local script copy. A PATH fallback prepends - ``$HOME/.local/bin`` (uv tool install's default bin dir) so the - hook works when triggered from GUI editors (VS Code / IntelliJ - source-control panels) whose process environment may not include - the user's shell PATH. +def _uninstall_git_pre_commit_hook(project_path: Path) -> bool: + """Remove any previously-installed RPG-Kit ``pre-commit`` block. + + Pre-commit was retired in favour of ``post-commit`` only: the + pre-commit sync ran ``--staged-only`` and was immediately followed + by the full post-commit sync, so its output had a ~1 sec lifetime + and added latency to every ``git commit`` for no observable benefit. + Existing workspaces upgraded via ``rpgkit init`` / ``rpgkit update`` + have their pre-commit block stripped here; user-authored hook + content (and other tools' blocks such as husky / pre-commit / + lefthook) is preserved untouched. + + Returns ``True`` when the workspace had a hooks dir to clean, + ``False`` only when no git checkout was found at all. """ hooks_dir = _resolve_git_hooks_dir(project_path) if hooks_dir is None: return False - # Level-1 hook: shell stub delegates everything to ``rpgkit hook`` - # so path resolution / logging / locking live in one Python place. - # Legacy shapes (pre-Level-1) are stripped on upgrade. - marker = "# RPG-Kit: pre-commit dispatcher" - body = ( - f"{marker}\n" - f"{_HOOK_PATH_FALLBACK}\n" - f"rpgkit hook pre-commit 2>/dev/null || true" - ) - return _install_hook_snippet( - hooks_dir, - "pre-commit", - "pre-commit", - body, - legacy_blocks=( - ("# RPG-Kit: full RPG sync on commit", 2), - ("# RPG-Kit: incremental RPG sync on commit", 3), - ), + hook_path = hooks_dir / "pre-commit" + if not hook_path.is_file(): + return True + + existing = hook_path.read_text(encoding="utf-8") + legacy = ( + ("# RPG-Kit: pre-commit dispatcher", 3), + ("# RPG-Kit: full RPG sync on commit", 2), + ("# RPG-Kit: incremental RPG sync on commit", 3), ) + cleaned = _strip_hook_block(existing, "pre-commit", legacy).rstrip("\n") + + # If nothing user-authored remains, delete the hook file so git + # falls back to its default no-hook behaviour. + if not cleaned.strip() or cleaned.strip() == "#!/bin/sh": + try: + hook_path.unlink() + except OSError: + pass + else: + hook_path.write_text(cleaned + "\n", encoding="utf-8") + hook_path.chmod(0o755) + return True def _install_git_post_merge_hook(project_path: Path) -> bool: @@ -2866,12 +2868,13 @@ def _install_hooks( ``.vscode/tasks.json`` that runs the same status command on workspace open — VS Code's closest analogue to a SessionStart hook for GitHub Copilot. - - All: appends an RPG incremental sync (``update_graphs.py sync``) - to ``.git/hooks/pre-commit`` AND ``.git/hooks/post-merge``. - The pre-commit hook uses ``--staged-only`` so it sees only what's - about to be committed; the post-merge hook (fired after - ``git pull`` / ``git merge``) considers the whole working tree - so teammate-incoming changes get picked up immediately. + - All: installs an RPG sync trigger on ``.git/hooks/post-commit`` + (fired after every successful commit) AND ``.git/hooks/post-merge`` + (fired after ``git pull`` / ``git merge`` so teammate-incoming + changes get picked up immediately). Any legacy ``pre-commit`` + block from earlier releases is stripped on upgrade — the design + now relies on post-commit only, so commit latency stays low and + the inner-git history is cleaner. Complements the MCP server already registered in ``.mcp.json`` / ``.vscode/mcp.json``. """ @@ -2884,8 +2887,8 @@ def _install_hooks( _install_copilot_hooks(project_path) installed.append("copilot") - if _install_git_pre_commit_hook(project_path): - installed.append("git:pre-commit") + # Strip any leftover pre-commit block from older installs. + _uninstall_git_pre_commit_hook(project_path) if _install_git_post_commit_hook(project_path): installed.append("git:post-commit") if _install_git_post_merge_hook(project_path): @@ -5114,7 +5117,7 @@ def _hook_spawn_background( hidden=True, help="Internal git-hook dispatcher (called from .git/hooks/*).", ) -def hook(name: str = typer.Argument(..., help="Hook name: pre-commit | post-commit | post-merge")) -> None: +def hook(name: str = typer.Argument(..., help="Hook name: post-commit | post-merge")) -> None: """Dispatch from ``.git/hooks/`` to the matching Python handler. Resolves the current workspace via the standard cwd-walk, attaches @@ -5123,6 +5126,12 @@ def hook(name: str = typer.Argument(..., help="Hook name: pre-commit | post-comm swallowed (logged, never raised) so a misbehaving hook never blocks the user's git operation. + Supported hooks: ``post-commit`` and ``post-merge``. The dispatcher + also accepts ``pre-commit`` as a deliberate no-op for backward + compatibility — old workspaces whose hook file still calls + ``rpgkit hook pre-commit`` should be cleaned up on the next + ``rpgkit init`` / ``rpgkit update`` run, which strips the block. + All ``rpgkit script`` subprocess invocations inherit two env vars: * ``RPGKIT_HOOK`` -- the hook name (``post-commit`` etc.) @@ -5132,9 +5141,8 @@ def hook(name: str = typer.Argument(..., help="Hook name: pre-commit | post-comm (:func:`rpgkit_cli._inner_git._build_message`) so ``git log`` in the home-side repo reads as a timeline of *user activity*, e.g.:: - [hook:post-commit @ a1b2c3d] update-rpg [hook:post-commit @ a1b2c3d] sync - [hook:pre-commit @ 9f8e7d6] sync --staged-only + [hook:post-merge @ 9f8e7d6] sync """ from . import _storage @@ -5162,11 +5170,10 @@ def hook(name: str = typer.Argument(..., help="Hook name: pre-commit | post-comm _hook_log_line(log_path, f"== {name} fired @ {sha} (ws={ws})") if name == "pre-commit": - _hook_run_foreground( - ws, log_path, env, - ["update_graphs.py", "sync", "--staged-only"], - "sync-staged", - ) + # Retired: pre-commit is now a deliberate no-op for backward + # compatibility with workspaces whose stub hasn't been + # stripped yet. Just log and exit success so git proceeds. + _hook_log_line(log_path, "pre-commit hook is a no-op (retired)") elif name == "post-merge": _hook_run_foreground( ws, log_path, env, From 71b0be5ae244fcd7a440f548af9f2edec462abff Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 11:33:33 +0800 Subject: [PATCH 23/31] docs: refresh for home-side runtime store + retired pre-commit hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.zh-CN.md: switch to 'Coding Agent' / 'workflow' English terminology (avoids translation drift to ja/ko/hi); rewrite '快速开始:已有仓库' to not require copying the repo; rewrite 'rpgkit init 之后会发生什么' with workspace-side vs home-side split, three-bullet rationale, multi-project parallelism callout; add troubleshooting entries for finding the home-side hash, cleaning home-side space, and full reset; split platform tables (Agent x Surface and OS x Shell) with legend. docs/project-structure.md: fix self-contradiction (reports stay inside workspace, not outside); fix stale '.rpgkit/data/' path reference; add '.git/hooks/' (post-commit + post-merge only) and '.rpgkit/reports/' to the workspace file tree; replace the Python hashlib one-liner with 'rpgkit version' guidance; add 'Quick reference: where does each file live?' table covering workspace-side vs home-side artefacts. --- RPG-Kit/README.zh-CN.md | 68 +++++++++++++++---------------- RPG-Kit/docs/project-structure.md | 36 ++++++++++++---- 2 files changed, 60 insertions(+), 44 deletions(-) diff --git a/RPG-Kit/README.zh-CN.md b/RPG-Kit/README.zh-CN.md index f46a9c3..2d29852 100644 --- a/RPG-Kit/README.zh-CN.md +++ b/RPG-Kit/README.zh-CN.md @@ -8,6 +8,7 @@ हिन्दी

+ ## 让编码智能体先规划,再编辑 编码智能体擅长局部编辑,但仓库级任务如果缺少稳定的规划结构往往会失败:需求漂移、架构决策丢失、多文件生成前后不一致、更新可能错过隐藏依赖。 @@ -22,11 +23,11 @@ RPG-Kit 为 Claude Code 和 GitHub Copilot 提供一个面向仓库级编码的* ### 选择你的工作流 -| 目标 | 工作流 | 从这里开始 | -|---|---|---| -| 从需求构建一个新仓库 | Build 工作流(requirements → RPG → code) | [`快速开始:新仓库`](#快速开始新仓库) | -| 理解一个已有仓库 | Understand 工作流(repository → RPG → search/explore) | [`快速开始:已有仓库`](#快速开始已有仓库) | -| 更新一个已有仓库 | Update 工作流(change request → affected RPG nodes → edit plan → code/RPG update) | [`快速开始:已有仓库`](#快速开始已有仓库) | +| 目标 | 工作流 | 从这里开始 | +| -------------------- | ------------------------------------------------------------ | ----------------------------------------- | +| 从需求构建一个新仓库 | Build 工作流(requirements → RPG → code) | [`快速开始:新仓库`](#快速开始新仓库) | +| 理解一个已有仓库 | Understand 工作流(repository → RPG → search/explore) | [`快速开始:已有仓库`](#快速开始已有仓库) | +| 更新一个已有仓库 | Update 工作流(change request → affected RPG nodes → edit plan → code/RPG update) | [`快速开始:已有仓库`](#快速开始已有仓库) | ### 详细流水线 @@ -35,6 +36,7 @@ RPG-Kit 为 Claude Code 和 GitHub Copilot 提供一个面向仓库级编码的*
完整的命令级工作流图 + ```text Forward Direction: Requirements → RPG → Code @@ -79,7 +81,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit 实际效果 -下图是为本仓库生成的图可视化的一部分。运行 `/rpgkit.encode`,然后打开 `.rpgkit/data/rpg.html` 浏览完整的交互式图。 +下图是为本仓库生成的图可视化的一部分。运行 `/rpgkit.encode`,然后打开 `.rpgkit/reports/rpg.html` 浏览完整的交互式图。 ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -90,7 +92,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree - Python 3.12+ - [uv](https://docs.astral.sh/uv/) - Git -- 一个已安装并完成身份验证的 AI 编码智能体 CLI:[GitHub Copilot](https://docs.github.com/en/copilot) 或 [Claude Code](https://docs.anthropic.com/en/docs/claude-code/setup) +- 一个已安装并完成身份验证的 Coding Agent CLI:[GitHub Copilot](https://docs.github.com/en/copilot) 或 [Claude Code](https://docs.anthropic.com/en/docs/claude-code/setup) ### 安装 RPG-Kit @@ -122,7 +124,6 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K ```bash rpgkit init my-project --ai claude --script sh rpgkit init my-project --ai copilot - rpgkit init my-project --github-token $GITHUB_TOKEN ``` 2. **[可选]** 把你的需求文档放在 `my-project/docs/`。 @@ -145,7 +146,9 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K [Optional] /rpgkit.rpg_edit ``` -RPG-Kit 会渐进式地创建 `.rpgkit/data/rpg.json`,并用它把需求、规划产物、生成的代码和依赖信息保持对齐。 + 注意:copilot不支持自定义命令,需要先 /agent 切换到指定agent,然后输入 start 以开始运行 + +RPG-Kit 会渐进式地在 home-side 运行时目录(`~/.rpgkit/workspaces//data/rpg.json`)里创建 `rpg.json`,并用它把需求、规划产物、生成的代码和依赖信息保持对齐。你的工作区源文件不会被污染。 ## 快速开始:已有仓库 @@ -158,9 +161,8 @@ RPG-Kit 会渐进式地创建 `.rpgkit/data/rpg.json`,并用它把需求、规 ```bash mkdir my-project - cp -r existing-repo/ my-project/ - cd my-project - rpgkit init . --encode + cd existing-repo/ + rpgkit init . --encode # --encode 会根据当前的代码生成rpg ``` 如果你想跳过非空目录的确认提示: @@ -171,7 +173,7 @@ RPG-Kit 会渐进式地创建 `.rpgkit/data/rpg.json`,并用它把需求、规 2. 在仓库里启动你的 AI 编码智能体。 -3. 通过 MCP 工具和 slash 命令使用生成的 RPG: +3. 【可选】通过 MCP 工具和 slash 命令使用生成的 RPG,以下命令只在手动运行时需要: ```text /rpgkit.encode # 需要时重建完整 RPG @@ -179,37 +181,39 @@ RPG-Kit 会渐进式地创建 `.rpgkit/data/rpg.json`,并用它把需求、规 /rpgkit.rpg_edit # 图感知的代码编辑 ``` -4. 提交后,RPG-Kit hook 会把 `.rpgkit/data/rpg.json`、`.rpgkit/data/dep_graph.json` 和 `.rpgkit/data/rpg.html` 与代码变更保持对齐。如果 hook 失败或被跳过,运行 `/rpgkit.update_rpg`。 +4. 每次 commit 后,RPG-Kit 安装的 git hook 会自动调用 `rpgkit hook ` 调度器,更新RPG,与代码变更保持对齐。如果 hook 失败或被跳过,可以手动运行 `/rpgkit.update_rpg`。 ## `rpgkit init` 之后会发生什么 -`rpgkit init` 不会修改你的源文件。它会在你的代码旁边添加命令定义、运行时脚本、MCP 配置和生成的图数据。 +`rpgkit init` 不会修改你的源文件,**也不会在你的工作区写入运行时状态**。它只在你的工作区添加命令定义、MCP 配置和 hooks,所有 RPG-Kit 的运行时数据(脚本、产物、日志、报告)都放在 home-side 目录 `~/.rpgkit/workspaces//` 下,由工作区绝对路径派生的 hash 隔离。 ```text my-project/ ├── docs/ # /rpgkit.feature_spec 的可选需求文档 ├── .github/ or .claude/ # AI 助手的命令定义和设置 ├── .vscode/ # 适用时的 Copilot/VS Code MCP 配置 -└── .rpgkit/ # RPG-Kit 运行时 - ├── scripts/ # 流水线脚本和支持包 - ├── data/ # 生成的产物,包括 rpg.json 和 dep_graph.json - ├── logs/ # 各阶段执行日志 - └── reports/ # 生成时的审查与诊断报告 +├── .rpgkit/ # 包含生成的报告 和 配置文件 ``` 完整的目录布局和数据文件参考见 [docs/project-structure.md](docs/project-structure.md)。 ## 支持的平台 -| 平台 | Claude Code | GitHub Copilot | Codex | -| ------------------- | ----------- | -------------- | ----- | -| CLI 使用 | ✅ | ✅ (No MCP) | ⌛ | -| VS Code 扩展使用 | ✅ | ✅ | ⌛ | +**Coding Agent 支持**: + +| Agent | CLI 使用 | VS Code 扩展使用 | +| -------------- | -------- | ---------------- | +| Claude Code | ✅ | ✅ | +| GitHub Copilot | ✅ | ✅ | +| Codex | ⌛ | ⌛ | -| 脚本 | Linux | Windows | Mac | -| ---- | ----- | ------- | --- | -| sh | ✅ | ⌛ | ⌛ | -| ps | N/A | ⌛ | ⌛ | +**操作系统支持**: + +| 操作系统 | 状态 | +| -------- | ---- | +| Linux | ✅ | +| macOS | ⌛ | +| Windows | ⌛ | ## 文档 @@ -228,16 +232,10 @@ my-project/ **找不到 AI 助手 CLI**:运行 `rpgkit check`,安装并完成所选助手 CLI 的身份验证,然后重新运行 `rpgkit init` 或 `rpgkit update`。 -**MCP 工具报告 `rpg_unavailable`**:运行 `/rpgkit.encode` 来创建 `.rpgkit/data/rpg.json`。 - -**增量更新失败**:检查 `.rpgkit/logs/update_rpg.log`,然后运行 `/rpgkit.update_rpg`。 - -**因为速率限制或私有仓库访问导致模板下载失败**:传递 `--github-token $GITHUB_TOKEN`,或设置 `GH_TOKEN` / `GITHUB_TOKEN`。 - ## 许可证 MIT License —— 详情见 [LICENSE](LICENSE)。 ## 致谢 -基于 [GitHub Spec-Kit](https://github.com/github/spec-kit)。 +基于 [GitHub Spec-Kit](https://github.com/github/spec-kit)。 \ No newline at end of file diff --git a/RPG-Kit/docs/project-structure.md b/RPG-Kit/docs/project-structure.md index 3f66618..9b0fc68 100644 --- a/RPG-Kit/docs/project-structure.md +++ b/RPG-Kit/docs/project-structure.md @@ -6,7 +6,7 @@ RPG-Kit installs alongside your project code: the directory you run `rpgkit init - `rpgkit init my-project` creates `my-project/` containing both your source code (`src/`, `tests/`, `docs/`) and RPG-Kit's in-workspace configuration files (`.rpgkit/config.toml`, `.claude/`, `.github/`, `.vscode/`, depending on the selected agent). - `rpgkit init --here` inside an existing git repository adds RPG-Kit on top of the existing code without moving the repository. -- A single `.git` repository tracks user-owned code and any RPG-Kit files the user chooses to commit. **Runtime data, logs, reports, and the inner-git snapshot repo all live outside the workspace** under `~/.rpgkit/workspaces//`, so nothing generated by RPG-Kit pollutes your repo or accidentally gets committed. +- A single `.git` repository tracks user-owned code and any RPG-Kit files the user chooses to commit. **Runtime data, logs, and the inner-git snapshot repo all live outside the workspace** under `~/.rpgkit/workspaces//`, so generated artefacts don't pollute your repo or accidentally get committed. Only a small set of user-facing files (`.rpgkit/config.toml`, `.rpgkit/reports/*.html`) stay inside the workspace. ## After `rpgkit init` @@ -40,8 +40,13 @@ my-project/ │ ├── mcp.json # MCP server registration │ └── tasks.json # Optional workspace tasks └── .rpgkit/ - ├── config.toml # Workspace AI / config (committed). See docs/configuration.md - └── .source # Provisioning channel marker: "bundle" or "legacy" +│ ├── config.toml # Workspace AI / config (committed). See docs/configuration.md +│ ├── .source # Provisioning channel marker: "bundle" or "legacy" +│ └── reports/ # User-facing HTML reports (rpg.html, review HTML, ...) +└── .git/ # Your existing git repo + └── hooks/ # Installed by `rpgkit init` + ├── post-commit # Single line: `rpgkit hook post-commit` + └── post-merge # Single line: `rpgkit hook post-merge` ``` ### Out-of-workspace runtime store @@ -65,7 +70,24 @@ The hash is computed as `sha256(os.path.realpath(workspace_path))[:12]`, so movi The agent configuration directory varies by the selected AI assistant and release package. For the verified CLI path, `--ai claude` installs `.claude/commands/`, while `--ai copilot` installs `.github/agents/`, `.github/prompts/`, and `.vscode/mcp.json`. -Command definitions are installed into the AI-agent-specific folder. Normal users should not need to edit `.rpgkit/data/` manually. +Command definitions are installed into the AI-agent-specific folder. Normal users should not need to inspect `~/.rpgkit/workspaces//data/` directly—run `rpgkit version` from the workspace to see all relevant paths. + +### Quick reference: where does each file live? + +| Artefact | Location | +|---|---| +| Your source code | `/` | +| Workspace AI config | `/.rpgkit/config.toml` | +| User-facing HTML reports (`rpg.html`, …) | `/.rpgkit/reports/` | +| Agent command definitions | `/.claude/` or `/.github/` | +| MCP / VS Code config | `/.vscode/` | +| Git hooks (`pre-commit`, `post-commit`, `post-merge`) | `/.git/hooks/` | +| Generated data (`rpg.json`, `dep_graph.json`, …) | `~/.rpgkit/workspaces//data/` | +| Per-stage logs | `~/.rpgkit/workspaces//logs/` | +| Inner-git snapshot repo | `~/.rpgkit/workspaces//.git/` | +| Pipeline scripts (read-only) | inside the installed `rpgkit-cli` wheel | + +To see the resolved paths for the current workspace, run `rpgkit version` from anywhere inside it. ## Generated Data Files @@ -131,8 +153,4 @@ Runtime logs are written under `~/.rpgkit/workspaces//logs/`, for example: Execution traces are written under `~/.rpgkit/workspaces//data/trajectory/`. Review or diagnostic artifacts may be written under `/.rpgkit/reports/` when a command generates them. -To discover the hash for the current workspace, run any rpgkit command with `--verbose`, or use Python: - -```bash -python3 -c 'import hashlib, os, sys; print(hashlib.sha256(os.path.realpath(sys.argv[1]).encode()).hexdigest()[:12])' . -``` +To discover the home-side paths (data / logs / inner-git) for the current workspace, run `rpgkit version` from anywhere inside it—the relevant lines are labelled **Workspace**, **Data**, **Logs**, and **Inner git**. From f15f55c937ec5b296744db833bfb0517b5a8715a Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 11:33:33 +0800 Subject: [PATCH 24/31] chore(lint): mirror markdownlint config inside RPG-Kit for local runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markdownlint-cli2 only walks up from each linted file; running it from RPG-Kit/ never reached the workspace-root config. Add a sibling config that disables MD013 / MD041 / MD060 (line length, first-line H1, double-width column alignment — all false positives on slash-command templates and CJK docs) and ignores .venv/, plans/, workspace/ as non-published trees. --- RPG-Kit/.markdownlint-cli2.jsonc | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 RPG-Kit/.markdownlint-cli2.jsonc diff --git a/RPG-Kit/.markdownlint-cli2.jsonc b/RPG-Kit/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..27f3ac6 --- /dev/null +++ b/RPG-Kit/.markdownlint-cli2.jsonc @@ -0,0 +1,37 @@ +{ + // Mirror of the workspace-root `.markdownlint-cli2.jsonc` so running + // `markdownlint-cli2` from `RPG-Kit/` picks up the same rules. + // The slash-command templates under `templates/commands/` are prompt + // material consumed verbatim by Coding Agents — wrapping at 80 cols + // or forcing an H1 would damage their semantics, so the corresponding + // rules are disabled here. + // + // https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md + "config": { + "default": true, + "MD003": { "style": "atx" }, + "MD007": { "indent": 2 }, + "MD013": false, + "MD024": { "siblings_only": true }, + "MD033": false, + "MD041": false, + "MD049": { "style": "asterisk" }, + "MD050": { "style": "asterisk" }, + // MD060 cannot count double-width CJK / emoji characters correctly, + // so visually-aligned tables containing ✅ / ⌛ etc. trigger false + // positives. Disable. + "MD060": false + }, + "ignores": [ + ".genreleases/", + ".pytest_cache/", + "**/__pycache__/", + ".venv/", + "node_modules/", + // Internal design notes / WIP plans — not user-facing docs. + "plans/", + // The entire workspace/ tree is for e2e fixtures + backups; not + // part of rpgkit's published docs. + "workspace/" + ] +} From 6ecc2fa39351e251535d6bf18e2f54f79d2300e5 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 12:47:33 +0800 Subject: [PATCH 25/31] fix(cli): remove browser-dependent view-graph command Remove the view-graph CLI command and update docs to direct users to rpgkit version and the generated reports path instead. This avoids noisy browser-launch failures in headless and WSL environments while keeping the graph report discoverable. --- RPG-Kit/docs/cli-reference.md | 17 ------ RPG-Kit/docs/commands.md | 2 +- RPG-Kit/docs/configuration.md | 2 +- RPG-Kit/docs/project-structure.md | 2 +- RPG-Kit/scripts/update_graphs.py | 4 +- RPG-Kit/src/rpgkit_cli/__init__.py | 89 +----------------------------- 6 files changed, 5 insertions(+), 111 deletions(-) diff --git a/RPG-Kit/docs/cli-reference.md b/RPG-Kit/docs/cli-reference.md index 970ebce..d46951e 100644 --- a/RPG-Kit/docs/cli-reference.md +++ b/RPG-Kit/docs/cli-reference.md @@ -87,23 +87,6 @@ Since the global-install layout, `rpgkit update` performs a **best-effort silent - `--pull` and `--no-pull` are mutually exclusive; passing both exits with status 2. - A loop guard environment variable (`RPGKIT_UPGRADE_DONE`) is set across the re-exec to guarantee at most one upgrade attempt per invocation. -## `rpgkit view-graph` - -Open the most recent `rpg.html` visualisation for the current workspace in your default browser. Walks up from the current directory to find the workspace root, then locates `rpg.html` under `/.rpgkit/reports/` (preferred, may be checked into git) or `~/.rpgkit/workspaces//data/` as a fallback for encoder output not yet promoted to reports. - -```bash -rpgkit view-graph -rpgkit view-graph --no-open # print the file URI but do not launch a browser -``` - -### Exit codes - -| Code | Meaning | -| ---- | ------- | -| `0` | Found `rpg.html` and (unless `--no-open`) opened it | -| `1` | Not inside an RPG-Kit workspace | -| `2` | Workspace found but no `rpg.html` has been generated yet (run `/rpgkit.encode` or the forward pipeline first) | - ### Provisioning sources Since `0.1.3`, `rpgkit init` and `rpgkit update` provision from two channels: diff --git a/RPG-Kit/docs/commands.md b/RPG-Kit/docs/commands.md index f06d5f1..20f44ef 100644 --- a/RPG-Kit/docs/commands.md +++ b/RPG-Kit/docs/commands.md @@ -6,7 +6,7 @@ RPG-Kit provides 13 slash commands that work in three paths: - **Reverse encoder:** Existing code → RPG - **Surgical edit:** Natural-language changes applied to code, RPG, and dependency graph together -> **Note on data paths.** Throughout this document, paths shown as `.rpgkit/data/...` and `.rpgkit/logs/...` are stable logical names. The actual files live **outside the workspace** under `~/.rpgkit/workspaces//{data,logs}/` so that runtime artefacts never enter the user's git repository. Reports (`rpg.html`, review HTML, etc.) stay in the workspace at `/.rpgkit/reports/` because they are small user-facing artefacts users may want to commit. Use `rpgkit view-graph` to open `rpg.html` without having to look up the hash. See [project-structure.md](project-structure.md) for the full layout. +> **Note on data paths.** Throughout this document, paths shown as `.rpgkit/data/...` and `.rpgkit/logs/...` are stable logical names. The actual files live **outside the workspace** under `~/.rpgkit/workspaces//{data,logs}/` so that runtime artefacts never enter the user's git repository. Reports (`rpg.html`, review HTML, etc.) stay in the workspace at `/.rpgkit/reports/` because they are small user-facing artefacts users may want to commit. Run `rpgkit version` from inside the workspace to see the resolved Data / Logs paths. See [project-structure.md](project-structure.md) for the full layout. ## Command Overview diff --git a/RPG-Kit/docs/configuration.md b/RPG-Kit/docs/configuration.md index 11af07d..b796573 100644 --- a/RPG-Kit/docs/configuration.md +++ b/RPG-Kit/docs/configuration.md @@ -2,7 +2,7 @@ This document covers RPG-Kit configuration that is useful after installation: AI assistant setup, MCP registration, auto-approval, hooks, and initial encoding. -> **Data paths.** References below such as `.rpgkit/data/rpg.json` and `.rpgkit/logs/...` are logical names. Runtime files actually live under `~/.rpgkit/workspaces//{data,logs}/` so they stay outside your git repo. Reports stay in the workspace at `/.rpgkit/reports/`. The MCP server, hooks, and pipeline scripts all resolve the home-dir location automatically from the workspace root. Use `rpgkit view-graph` to open the visualisation without computing the hash; see [project-structure.md](project-structure.md) for the full layout. +> **Data paths.** References below such as `.rpgkit/data/rpg.json` and `.rpgkit/logs/...` are logical names. Runtime files actually live under `~/.rpgkit/workspaces//{data,logs}/` so they stay outside your git repo. Reports stay in the workspace at `/.rpgkit/reports/`. The MCP server, hooks, and pipeline scripts all resolve the home-dir location automatically from the workspace root. Run `rpgkit version` from inside the workspace to see the resolved Data / Logs paths; see [project-structure.md](project-structure.md) for the full layout. ## AI Assistant CLI Requirements diff --git a/RPG-Kit/docs/project-structure.md b/RPG-Kit/docs/project-structure.md index 9b0fc68..160ef83 100644 --- a/RPG-Kit/docs/project-structure.md +++ b/RPG-Kit/docs/project-structure.md @@ -64,7 +64,7 @@ Starting from the global-install layout, all runtime state lives under your home Reports (`rpg.html`, review HTML, …) stay **inside** the workspace at `/.rpgkit/reports/` because they are small, user-facing artefacts that benefit from sitting next to the code (and may be committed). -The hash is computed as `sha256(os.path.realpath(workspace_path))[:12]`, so moving or renaming the workspace yields a different home directory. To open the RPG visualisation without remembering the hash, run [`rpgkit view-graph`](cli-reference.md) from anywhere inside the workspace. +The hash is computed as `sha256(os.path.realpath(workspace_path))[:12]`, so moving or renaming the workspace yields a different home directory. Run `rpgkit version` from inside the workspace to see the resolved paths (the **Data**, **Logs**, and **Inner git** lines). > Pipeline scripts (formerly materialised into `.rpgkit/scripts/`) now live inside the installed `rpgkit-cli` wheel under `rpgkit_cli/core_pack/scripts/` and are invoked via the global [`rpgkit script `](cli-reference.md) command. They are no longer copied into each workspace, so `rpgkit init` produces a much smaller footprint and a single source of truth per CLI install. diff --git a/RPG-Kit/scripts/update_graphs.py b/RPG-Kit/scripts/update_graphs.py index 3c7fc76..df0f981 100644 --- a/RPG-Kit/scripts/update_graphs.py +++ b/RPG-Kit/scripts/update_graphs.py @@ -600,9 +600,7 @@ def _format_status_for_agent(status: dict) -> str: For Claude Code ``SessionStart`` hooks, stdout is injected verbatim into the agent's context. For VS Code tasks running on folderOpen, the user sees this text in a terminal; Copilot can read it on - request. The text intentionally mirrors the ``code-review-graph`` - pattern: state what's available + a short list of MCP tools to - prefer over raw file scans. + request. """ lines = [] rpg_broken = "rpg_error" in status diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 0140ca4..240e0be 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -4239,7 +4239,7 @@ def init( steps_lines.append( f"{step_num}. You can inspect each step's output under [cyan]~/.rpgkit/workspaces//data/[/cyan], " f"and review detailed execution trajectories under [cyan]~/.rpgkit/workspaces//data/trajectory/[/cyan]. " - f"Use [cyan]rpgkit view-graph[/cyan] from anywhere inside this workspace to open the RPG visualisation." + f"Run [cyan]rpgkit version[/cyan] from inside the workspace to see the resolved Data / Logs / Inner-git paths." ) step_num += 1 @@ -5213,93 +5213,6 @@ def hook(name: str = typer.Argument(..., help="Hook name: post-commit | post-mer raise typer.Exit(0) -@app.command("view-graph") -def view_graph( - no_open: bool = typer.Option( - False, - "--no-open", - help=( - "Print the path to rpg.html instead of opening it. Use " - "this in headless environments or when piping the path " - "into another tool." - ), - ), -) -> None: - """Open the workspace's RPG visualisation in the default browser. - - Resolves the workspace via cwd-walk-up (so you can run this from - any subdirectory of an rpgkit workspace) and then locates - ``rpg.html`` in priority order: - - 1. ``/.rpgkit/reports/rpg.html`` (workspace-local, when present) - 2. ``~/.rpgkit/workspaces//data/rpg.html`` (default location written by the encoder) - - The visualisation is generated by ``rpgkit script - rpg_encoder/run_encode.py`` (full encode) and refreshed by the - post-commit hook's ``update_graphs.py sync`` whenever ``rpg.json`` - changes, so it should be in sync with the current state of the - workspace. - - Exits non-zero with a clear message if (a) the cwd isn't in an - rpgkit workspace, or (b) the workspace exists but ``rpg.html`` is - missing (typically because ``/rpgkit.encode`` hasn't been run yet). - """ - ws = _storage.find_workspace_root_from() - if ws is None: - console.print( - "[red]error:[/red] not in an rpgkit workspace. " - "[dim]Run [cyan]rpgkit init[/cyan] first, or `cd` into " - "a workspace.[/dim]" - ) - raise typer.Exit(1) - - # Look in workspace reports/ first (intended permanent location) - # then home data/ (where the encoder currently writes). Both - # paths are absolute by construction. - candidates: list[Path] = [ - _storage.workspace_reports_dir(ws) / "rpg.html", - _storage.workspace_data_dir(ws) / "rpg.html", - ] - html_path: Optional[Path] = next( - (p for p in candidates if p.is_file()), None - ) - - if html_path is None: - console.print( - "[red]error:[/red] no rpg.html found for workspace " - f"[cyan]{ws}[/cyan].\n" - "[dim]Run [cyan]/rpgkit.encode[/cyan] in your AI agent to " - "build the visualisation (or [cyan]rpgkit script " - "rpg_encoder/run_encode.py[/cyan] directly).[/dim]" - ) - raise typer.Exit(2) - - if no_open: - # Plain print (no markup) so the path pipes cleanly into other - # tools: ``rpgkit view-graph --no-open | xargs open`` etc. - print(str(html_path)) - return - - # Lazy import: webbrowser is rarely needed for other CLI paths. - import webbrowser - uri = html_path.as_uri() - console.print(f"[cyan]Opening[/cyan] {html_path}") - opened = False - try: - opened = webbrowser.open(uri, new=2) - except Exception as exc: # noqa: BLE001 - console.print(f"[yellow]webbrowser.open raised {exc}[/yellow]") - if not opened: - # Headless / no browser registered. Fall through to printing - # the path so the user can copy-paste it; exit 0 because the - # request was logically successful (we found the file). - console.print( - "[yellow]Could not launch a browser. Copy this URI into " - "one manually:[/yellow]" - ) - console.print(uri) - - @app.command() def check(): """Check that all required tools are installed.""" From b0a44587da980542b15501eb6252e03dcda745f6 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 12:47:42 +0800 Subject: [PATCH 26/31] docs(readme): align multilingual README guidance Use the current zh-CN README as the source structure and sync English, Japanese, Korean, and Hindi variants. Update installation, runtime storage, update workflow, platform support, and troubleshooting sections consistently across the localized READMEs. --- RPG-Kit/README.hi-IN.md | 75 ++++++++++++++++++------------- RPG-Kit/README.ja-JP.md | 75 ++++++++++++++++++------------- RPG-Kit/README.ko-KR.md | 75 ++++++++++++++++++------------- RPG-Kit/README.md | 98 ++++++++++++++++++----------------------- RPG-Kit/README.zh-CN.md | 37 +++++++++++----- 5 files changed, 206 insertions(+), 154 deletions(-) diff --git a/RPG-Kit/README.hi-IN.md b/RPG-Kit/README.hi-IN.md index a78bf62..60684a5 100644 --- a/RPG-Kit/README.hi-IN.md +++ b/RPG-Kit/README.hi-IN.md @@ -79,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit वास्तविक उपयोग में -नीचे दी गई छवि इस रिपॉज़िटरी के लिए जनरेट किए गए ग्राफ़ विज़ुअलाइज़ेशन का एक भाग है। `/rpgkit.encode` चलाएँ और पूर्ण इंटरैक्टिव ग्राफ़ देखने के लिए `.rpgkit/data/rpg.html` खोलें। +नीचे दी गई छवि इस रिपॉज़िटरी के लिए जनरेट किए गए ग्राफ़ विज़ुअलाइज़ेशन का एक भाग है। `/rpgkit.encode` चलाने के बाद, पूर्ण इंटरैक्टिव ग्राफ़ देखने के लिए `/.rpgkit/reports/rpg.html` खोलें। वर्तमान वर्कस्पेस के हल किए गए पथ देखने के लिए `rpgkit version` चलाएँ। ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -103,6 +103,8 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` +`0.1.3` से, wheel pipeline scripts और slash-command templates को packaged assets के रूप में शामिल करता है, इसलिए `rpgkit init` ऑफ़लाइन वातावरणों (जैसे air-gapped या corporate proxy वातावरण) में भी काम करता है। + ## Quick Start: नई रिपॉज़िटरी जब आप RPG-Kit से आवश्यकताओं को एक नए कोडबेस में बदलवाना चाहते हैं, तब इस मार्ग का उपयोग करें। @@ -122,7 +124,6 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K ```bash rpgkit init my-project --ai claude --script sh rpgkit init my-project --ai copilot - rpgkit init my-project --github-token $GITHUB_TOKEN ``` 2. **[वैकल्पिक]** अपने आवश्यकता दस्तावेज़ `my-project/docs/` में रखें। @@ -145,7 +146,13 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K [Optional] /rpgkit.rpg_edit ``` -RPG-Kit क्रमिक रूप से `.rpgkit/data/rpg.json` बनाता है और इसका उपयोग आवश्यकताओं, प्लानिंग आउटपुट, जनरेटेड कोड और dependency जानकारी को संरेखित रखने के लिए करता है। +> [!IMPORTANT] +> **हर Coding Agent का इनवोकेशन थोड़ा अलग होता है**: +> +> - **Claude Code**: चैट में सीधे `/rpgkit.feature_spec ...` टाइप करें — slash command पहचाने जाते हैं और संबंधित workflow ट्रिगर हो जाता है। +> - **GitHub Copilot CLI**: slash command समर्थित नहीं हैं (कस्टम agent समर्थित हैं), इसलिए पहले `/agent rpgkit.feature_spec` से लक्ष्य agent पर स्विच करें, फिर `start` टाइप करके इसका अंतर्निहित workflow चलाएँ। + +RPG-Kit क्रमिक रूप से `~/.rpgkit/workspaces//data/rpg.json` बनाता है और इसका उपयोग आवश्यकताओं, प्लानिंग आउटपुट, जनरेटेड कोड और dependency जानकारी को संरेखित रखने के लिए करता है। आपके वर्कस्पेस की स्रोत फ़ाइलें दूषित नहीं होंगी। ## Quick Start: मौजूदा रिपॉज़िटरी @@ -157,10 +164,8 @@ RPG-Kit क्रमिक रूप से `.rpgkit/data/rpg.json` बनात 1. रिपॉज़िटरी रूट में RPG-Kit को आरंभीकृत करें और प्रारंभिक ग्राफ़ बनाएँ: ```bash - mkdir my-project - cp -r existing-repo/ my-project/ - cd my-project - rpgkit init . --encode + cd existing-repo/ + rpgkit init . --encode # --encode वर्तमान कोड से RPG उत्पन्न करता है ``` यदि आप गैर-खाली निर्देशिका के लिए पुष्टि संकेत को छोड़ना चाहते हैं: @@ -171,7 +176,7 @@ RPG-Kit क्रमिक रूप से `.rpgkit/data/rpg.json` बनात 2. रिपॉज़िटरी में अपना AI कोडिंग एजेंट लॉन्च करें। -3. MCP टूल्स और स्लैश कमांड्स के माध्यम से जनरेटेड RPG का उपयोग करें: +3. **[वैकल्पिक]** MCP टूल्स और स्लैश कमांड्स के माध्यम से जनरेटेड RPG का उपयोग करें। नीचे दिए गए कमांड केवल मैन्युअल रूप से चलाने पर आवश्यक हैं: ```text /rpgkit.encode # आवश्यकता पड़ने पर पूर्ण RPG को पुनर्निर्मित करें @@ -179,37 +184,53 @@ RPG-Kit क्रमिक रूप से `.rpgkit/data/rpg.json` बनात /rpgkit.rpg_edit # ग्राफ़-जागरूक कोड संपादन ``` -4. कमिट के बाद, RPG-Kit hooks `.rpgkit/data/rpg.json`, `.rpgkit/data/dep_graph.json` और `.rpgkit/data/rpg.html` को कोड परिवर्तनों के साथ संरेखित रखते हैं। यदि hook विफल हो जाता है या छोड़ दिया जाता है, तो `/rpgkit.update_rpg` चलाएँ। +4. हर commit के बाद, RPG-Kit द्वारा इंस्टॉल किया गया git hook स्वचालित रूप से `rpgkit hook ` dispatcher को कॉल करता है, RPG को अपडेट करता है और उसे कोड परिवर्तनों के साथ संरेखित रखता है। यदि hook विफल हो जाता है या छोड़ दिया जाता है, तो `/rpgkit.update_rpg` मैन्युअल रूप से चलाएँ। ## `rpgkit init` के बाद क्या होता है -`rpgkit init` आपकी स्रोत फ़ाइलों को संशोधित नहीं करता है। यह आपके कोड के साथ-साथ कमांड परिभाषाएँ, रनटाइम स्क्रिप्ट्स, MCP कॉन्फ़िगरेशन और जनरेटेड ग्राफ़ डेटा जोड़ता है। +`rpgkit init` आपकी स्रोत फ़ाइलों को संशोधित नहीं करता है, **और आपके वर्कस्पेस में रनटाइम स्टेट नहीं लिखता है**। यह आपके वर्कस्पेस में केवल command definitions, MCP कॉन्फ़िगरेशन और hooks जोड़ता है। RPG-Kit का रनटाइम डेटा (outputs और logs) home-side निर्देशिका `~/.rpgkit/workspaces//` के अंतर्गत रखा जाता है, जो वर्कस्पेस के absolute path से जनित hash द्वारा अलग किया जाता है। ```text my-project/ ├── docs/ # /rpgkit.feature_spec के लिए वैकल्पिक आवश्यकता दस्तावेज़ -├── .github/ or .claude/ # AI सहायक कमांड परिभाषाएँ और सेटिंग्स +├── .github/ or .claude/ # Coding Agent कमांड परिभाषाएँ और सेटिंग्स ├── .vscode/ # लागू होने पर Copilot/VS Code MCP कॉन्फ़िगरेशन -└── .rpgkit/ # RPG-Kit रनटाइम - ├── scripts/ # पाइपलाइन स्क्रिप्ट्स और सहायक पैकेज - ├── data/ # जनरेटेड आउटपुट, जिसमें rpg.json और dep_graph.json शामिल हैं - ├── logs/ # प्रति-चरण निष्पादन लॉग - └── reports/ # जनरेट होने पर समीक्षा और निदान रिपोर्ट +├── .rpgkit/ # जनरेटेड रिपोर्ट और कॉन्फ़िगरेशन फ़ाइलें +└── .git/hooks/ # rpgkit init द्वारा इंस्टॉल किए गए post-commit / post-merge (प्रत्येक hook केवल एक पंक्ति: `rpgkit hook `) ``` पूर्ण लेआउट और डेटा फ़ाइल संदर्भ के लिए [docs/project-structure.md](docs/project-structure.md) देखें। +## RPG-Kit अपडेट करें + +```bash +uv tool install rpgkit-cli \ + --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" \ + --force \ + --reinstall + +# किसी मौजूदा वर्कस्पेस को अपडेट करें +cd +rpgkit update +``` + ## समर्थित प्लेटफ़ॉर्म्स -| प्लेटफ़ॉर्म | Claude Code | GitHub Copilot | Codex | -| ------------------------ | ----------- | -------------- | ----- | -| CLI उपयोग | ✅ | ✅ (No MCP) | ⌛ | -| VS Code एक्सटेंशन उपयोग | ✅ | ✅ | ⌛ | +**Coding Agent समर्थन**: + +| Agent | CLI उपयोग | VS Code एक्सटेंशन उपयोग | +| -------------- | --------- | ----------------------- | +| Claude Code | ✅ | ✅ | +| GitHub Copilot | ✅ | ✅ | +| Codex | ⌛ | ⌛ | + +**ऑपरेटिंग सिस्टम समर्थन**: -| स्क्रिप्ट | Linux | Windows | Mac | -| --------- | ----- | ------- | --- | -| sh | ✅ | ⌛ | ⌛ | -| ps | N/A | ⌛ | ⌛ | +| ऑपरेटिंग सिस्टम | स्थिति | +| ---------------- | ------ | +| Linux | ✅ | +| macOS | ⌛ | +| Windows | ⌛ | ## दस्तावेज़ीकरण @@ -228,12 +249,6 @@ my-project/ **AI सहायक CLI नहीं मिला:** `rpgkit check` चलाएँ, चयनित सहायक CLI को इंस्टॉल और प्रमाणित करें, फिर `rpgkit init` या `rpgkit update` पुनः चलाएँ। -**MCP टूल्स `rpg_unavailable` की रिपोर्ट करते हैं:** `.rpgkit/data/rpg.json` बनाने के लिए `/rpgkit.encode` चलाएँ। - -**वृद्धिशील अपडेट विफल:** `.rpgkit/logs/update_rpg.log` की जाँच करें, फिर `/rpgkit.update_rpg` चलाएँ। - -**रेट लिमिट्स या निजी रिपॉज़िटरी एक्सेस के कारण टेम्पलेट डाउनलोड विफल:** `--github-token $GITHUB_TOKEN` पास करें या `GH_TOKEN` / `GITHUB_TOKEN` सेट करें। - ## लाइसेंस MIT License — विवरण के लिए [LICENSE](LICENSE) देखें। diff --git a/RPG-Kit/README.ja-JP.md b/RPG-Kit/README.ja-JP.md index f43136a..ddbee1e 100644 --- a/RPG-Kit/README.ja-JP.md +++ b/RPG-Kit/README.ja-JP.md @@ -79,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit の実例 -下の図は、本リポジトリに対して生成されたグラフ可視化の一部です。`/rpgkit.encode` を実行し、`.rpgkit/data/rpg.html` を開くと完全なインタラクティブグラフを閲覧できます。 +下の図は、本リポジトリに対して生成されたグラフ可視化の一部です。`/rpgkit.encode` を実行した後、`/.rpgkit/reports/rpg.html` を開くと完全なインタラクティブグラフを閲覧できます。現在のワークスペースの解決済みパスを見るには `rpgkit version` を実行してください。 ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -103,6 +103,8 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` +`0.1.3` 以降、wheel には pipeline scripts と slash-command templates が packaged assets として同梱されるため、`rpgkit init` はオフライン環境(air-gapped 環境や企業プロキシ環境など)でも動作します。 + ## クイックスタート: 新規リポジトリ 要件から新しいコードベースを生成したい場合は、こちらの手順を使います。 @@ -122,7 +124,6 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K ```bash rpgkit init my-project --ai claude --script sh rpgkit init my-project --ai copilot - rpgkit init my-project --github-token $GITHUB_TOKEN ``` 2. **[任意]** 要件ドキュメントを `my-project/docs/` に配置します。 @@ -145,7 +146,13 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K [Optional] /rpgkit.rpg_edit ``` -RPG-Kit は `.rpgkit/data/rpg.json` を段階的に作成し、それを使って要件・計画成果物・生成コード・依存情報を整合した状態に保ちます。 +> [!IMPORTANT] +> **コーディングエージェントごとに呼び出し方が異なります**: +> +> - **Claude Code**:チャットにそのまま `/rpgkit.feature_spec ...` と入力します。slash command が認識され、対応する workflow がトリガーされます。 +> - **GitHub Copilot CLI**:slash command はサポートされません(カスタム agent はサポート)。まず `/agent rpgkit.feature_spec` で目的の agent に切り替え、その後 `start` と入力して内蔵の workflow を実行します。 + +RPG-Kit は `~/.rpgkit/workspaces//data/rpg.json` を段階的に作成し、それを使って要件・計画成果物・生成コード・依存情報を整合した状態に保ちます。ワークスペースのソースファイルは汚染されません。 ## クイックスタート: 既存リポジトリ @@ -157,10 +164,8 @@ RPG-Kit は `.rpgkit/data/rpg.json` を段階的に作成し、それを使っ 1. リポジトリのルートで RPG-Kit を初期化し、初期グラフを構築します: ```bash - mkdir my-project - cp -r existing-repo/ my-project/ - cd my-project - rpgkit init . --encode + cd existing-repo/ + rpgkit init . --encode # --encode は現在のコードから RPG を生成します ``` 空でないディレクトリでの確認プロンプトをスキップしたい場合: @@ -171,7 +176,7 @@ RPG-Kit は `.rpgkit/data/rpg.json` を段階的に作成し、それを使っ 2. リポジトリで AI コーディングエージェントを起動します。 -3. 生成された RPG を MCP ツールおよびスラッシュコマンド経由で利用します: +3. **[任意]** 生成された RPG を MCP ツールおよびスラッシュコマンド経由で利用します。以下のコマンドは手動で実行する場合にのみ必要です: ```text /rpgkit.encode # 必要に応じて完全な RPG を再構築 @@ -179,37 +184,53 @@ RPG-Kit は `.rpgkit/data/rpg.json` を段階的に作成し、それを使っ /rpgkit.rpg_edit # グラフ認識型のコード編集 ``` -4. コミット後、RPG-Kit のフックが `.rpgkit/data/rpg.json`、`.rpgkit/data/dep_graph.json`、`.rpgkit/data/rpg.html` をコード変更に合わせて整合します。フックが失敗したりスキップされた場合は `/rpgkit.update_rpg` を実行してください。 +4. 各 commit の後、RPG-Kit がインストールした git hook が `rpgkit hook ` ディスパッチャを自動的に呼び出し、RPG を更新してコード変更と整合した状態に保ちます。hook が失敗したりスキップされたりした場合は、`/rpgkit.update_rpg` を手動で実行してください。 ## `rpgkit init` の後に起きること -`rpgkit init` はソースファイルを変更しません。コードのそばに、コマンド定義・ランタイムスクリプト・MCP 設定・生成されたグラフデータを追加します。 +`rpgkit init` はソースファイルを変更しません。また、**ワークスペースにランタイム状態を書き込みません**。ワークスペースには command 定義、MCP 設定、および hooks のみを追加します。RPG-Kit のランタイムデータ(成果物、ログ)は home-side ディレクトリ `~/.rpgkit/workspaces//` 下に配置され、ワークスペースの絶対パスから派生した hash で隔離されます。 ```text my-project/ ├── docs/ # /rpgkit.feature_spec 用の任意の要件ドキュメント -├── .github/ or .claude/ # AI アシスタントのコマンド定義と設定 +├── .github/ or .claude/ # Coding Agent のコマンド定義と設定 ├── .vscode/ # 該当する場合の Copilot/VS Code MCP 設定 -└── .rpgkit/ # RPG-Kit ランタイム - ├── scripts/ # パイプラインスクリプトおよびサポートパッケージ - ├── data/ # 生成成果物(rpg.json と dep_graph.json を含む) - ├── logs/ # ステージごとの実行ログ - └── reports/ # 生成時のレビュー・診断レポート +├── .rpgkit/ # 生成されたレポートと設定ファイル +└── .git/hooks/ # rpgkit init が設置する post-commit / post-merge(各 hook は 1 行のみ: `rpgkit hook `) ``` 完全なレイアウトとデータファイルのリファレンスは [docs/project-structure.md](docs/project-structure.md) を参照してください。 +## RPG-Kit の更新 + +```bash +uv tool install rpgkit-cli \ + --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" \ + --force \ + --reinstall + +# 既存のワークスペースを更新 +cd +rpgkit update +``` + ## 対応プラットフォーム -| プラットフォーム | Claude Code | GitHub Copilot | Codex | -| --------------------- | ----------- | -------------- | ----- | -| CLI 使用 | ✅ | ✅ (No MCP) | ⌛ | -| VS Code 拡張使用 | ✅ | ✅ | ⌛ | +**Coding Agent サポート**: + +| Agent | CLI 使用 | VS Code 拡張使用 | +| -------------- | -------- | ---------------- | +| Claude Code | ✅ | ✅ | +| GitHub Copilot | ✅ | ✅ | +| Codex | ⌛ | ⌛ | + +**オペレーティングシステムサポート**: -| スクリプト | Linux | Windows | Mac | -| ---------- | ----- | ------- | --- | -| sh | ✅ | ⌛ | ⌛ | -| ps | N/A | ⌛ | ⌛ | +| OS | 状態 | +| ------- | ---- | +| Linux | ✅ | +| macOS | ⌛ | +| Windows | ⌛ | ## ドキュメント @@ -228,12 +249,6 @@ my-project/ **AI アシスタント CLI が見つからない:** `rpgkit check` を実行し、選択したアシスタント CLI をインストールおよび認証し、`rpgkit init` または `rpgkit update` を再実行してください。 -**MCP ツールが `rpg_unavailable` を報告する:** `/rpgkit.encode` を実行して `.rpgkit/data/rpg.json` を作成してください。 - -**増分更新が失敗する:** `.rpgkit/logs/update_rpg.log` を確認し、`/rpgkit.update_rpg` を実行してください。 - -**レート制限またはプライベートリポジトリのアクセス権でテンプレートのダウンロードに失敗する:** `--github-token $GITHUB_TOKEN` を渡すか、`GH_TOKEN` / `GITHUB_TOKEN` を設定してください。 - ## ライセンス MIT License — 詳細は [LICENSE](LICENSE) を参照してください。 diff --git a/RPG-Kit/README.ko-KR.md b/RPG-Kit/README.ko-KR.md index d399691..39e9dfa 100644 --- a/RPG-Kit/README.ko-KR.md +++ b/RPG-Kit/README.ko-KR.md @@ -79,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit 실제 사용 예 -아래 이미지는 이 저장소에서 생성된 그래프 시각화의 일부입니다. `/rpgkit.encode` 를 실행하고 `.rpgkit/data/rpg.html` 을 열면 전체 인터랙티브 그래프를 탐색할 수 있습니다. +아래 이미지는 이 저장소에서 생성된 그래프 시각화의 일부입니다. `/rpgkit.encode` 를 실행한 후 `/.rpgkit/reports/rpg.html` 을 열면 전체 인터랙티브 그래프를 탐색할 수 있습니다. 현재 워크스페이스의 해결된 경로를 보려면 `rpgkit version` 을 실행하세요. ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -103,6 +103,8 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` +`0.1.3` 부터 wheel은 pipeline scripts와 slash-command templates를 packaged assets로 함께 제공하므로, `rpgkit init` 은 오프라인 환경(air-gapped 환경, 회사 프록시 환경 등)에서도 동작합니다. + ## Quick Start: 새 저장소 요구사항을 새 코드베이스로 만들고 싶을 때 이 경로를 사용하세요. @@ -122,7 +124,6 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K ```bash rpgkit init my-project --ai claude --script sh rpgkit init my-project --ai copilot - rpgkit init my-project --github-token $GITHUB_TOKEN ``` 2. **[선택]** 요구사항 문서를 `my-project/docs/` 에 둡니다. @@ -145,7 +146,13 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K [Optional] /rpgkit.rpg_edit ``` -RPG-Kit은 `.rpgkit/data/rpg.json` 을 점진적으로 생성하고, 이를 사용해 요구사항, 계획 산출물, 생성된 코드, 의존성 정보를 정합 상태로 유지합니다. +> [!IMPORTANT] +> **Coding Agent마다 호출 방식이 조금씩 다릅니다**: +> +> - **Claude Code**: 채팅에 직접 `/rpgkit.feature_spec ...` 을 입력하면 slash command가 인식되어 해당 workflow가 트리거됩니다. +> - **GitHub Copilot CLI**: slash command는 지원하지 않으나(커스텀 agent는 지원), 먼저 `/agent rpgkit.feature_spec` 으로 대상 agent로 전환한 다음 `start` 를 입력해 내장된 workflow를 실행합니다. + +RPG-Kit은 `~/.rpgkit/workspaces//data/rpg.json` 을 점진적으로 생성하고, 이를 사용해 요구사항, 계획 산출물, 생성된 코드, 의존성 정보를 정합 상태로 유지합니다. 워크스페이스의 소스 파일은 오염되지 않습니다. ## Quick Start: 기존 저장소 @@ -157,10 +164,8 @@ RPG-Kit은 `.rpgkit/data/rpg.json` 을 점진적으로 생성하고, 이를 사 1. 저장소 루트에서 RPG-Kit을 초기화하고 초기 그래프를 생성합니다: ```bash - mkdir my-project - cp -r existing-repo/ my-project/ - cd my-project - rpgkit init . --encode + cd existing-repo/ + rpgkit init . --encode # --encode 는 현재 코드로부터 RPG를 생성합니다 ``` 비어 있지 않은 디렉터리에 대한 확인 프롬프트를 건너뛰려면: @@ -171,7 +176,7 @@ RPG-Kit은 `.rpgkit/data/rpg.json` 을 점진적으로 생성하고, 이를 사 2. 저장소에서 AI 코딩 에이전트를 실행합니다. -3. MCP 도구와 슬래시 커맨드를 통해 생성된 RPG를 사용합니다: +3. **[선택]** MCP 도구와 슬래시 커맨드를 통해 생성된 RPG를 사용합니다. 아래 명령은 수동으로 실행할 때만 필요합니다: ```text /rpgkit.encode # 필요할 때 전체 RPG 재구축 @@ -179,37 +184,53 @@ RPG-Kit은 `.rpgkit/data/rpg.json` 을 점진적으로 생성하고, 이를 사 /rpgkit.rpg_edit # 그래프 인식 코드 편집 ``` -4. 커밋 후, RPG-Kit 훅이 `.rpgkit/data/rpg.json`, `.rpgkit/data/dep_graph.json`, `.rpgkit/data/rpg.html` 을 코드 변경에 맞춰 동기화합니다. 훅이 실패하거나 건너뛰어진 경우 `/rpgkit.update_rpg` 를 실행하세요. +4. 각 commit 후, RPG-Kit이 설치한 git hook이 `rpgkit hook ` 디스패처를 자동으로 호출해 RPG를 업데이트하고 코드 변경과 정합된 상태로 유지합니다. hook이 실패하거나 건너뛰어진 경우 `/rpgkit.update_rpg` 를 수동으로 실행하세요. ## `rpgkit init` 이후 일어나는 일 -`rpgkit init` 은 소스 파일을 수정하지 않습니다. 코드 옆에 커맨드 정의, 런타임 스크립트, MCP 구성, 생성된 그래프 데이터를 추가합니다. +`rpgkit init` 은 소스 파일을 수정하지 않습니다. 또한 **워크스페이스에 런타임 상태를 기록하지도 않습니다**. 워크스페이스에는 command 정의, MCP 구성, hooks만 추가합니다. RPG-Kit의 런타임 데이터(산출물, 로그)는 home-side 디렉터리 `~/.rpgkit/workspaces//` 아래에 배치되며, 워크스페이스 절대 경로에서 파생된 hash로 격리됩니다. ```text my-project/ ├── docs/ # /rpgkit.feature_spec 용 선택적 요구사항 문서 -├── .github/ or .claude/ # AI 어시스턴트 커맨드 정의 및 설정 +├── .github/ or .claude/ # Coding Agent 커맨드 정의 및 설정 ├── .vscode/ # 해당하는 경우 Copilot/VS Code MCP 구성 -└── .rpgkit/ # RPG-Kit 런타임 - ├── scripts/ # 파이프라인 스크립트와 지원 패키지 - ├── data/ # 생성된 산출물 (rpg.json과 dep_graph.json 포함) - ├── logs/ # 단계별 실행 로그 - └── reports/ # 생성 시의 리뷰 및 진단 리포트 +├── .rpgkit/ # 생성된 리포트와 설정 파일 +└── .git/hooks/ # rpgkit init 이 설치하는 post-commit / post-merge (각 hook은 단 한 줄: `rpgkit hook `) ``` 전체 레이아웃과 데이터 파일 참조는 [docs/project-structure.md](docs/project-structure.md) 를 참조하세요. +## RPG-Kit 업데이트 + +```bash +uv tool install rpgkit-cli \ + --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" \ + --force \ + --reinstall + +# 기존 워크스페이스 업데이트 +cd +rpgkit update +``` + ## 지원 플랫폼 -| 플랫폼 | Claude Code | GitHub Copilot | Codex | -| ------------------- | ----------- | -------------- | ----- | -| CLI 사용 | ✅ | ✅ (No MCP) | ⌛ | -| VS Code 확장 사용 | ✅ | ✅ | ⌛ | +**Coding Agent 지원**: + +| Agent | CLI 사용 | VS Code 확장 사용 | +| -------------- | -------- | ----------------- | +| Claude Code | ✅ | ✅ | +| GitHub Copilot | ✅ | ✅ | +| Codex | ⌛ | ⌛ | + +**운영 체제 지원**: -| 스크립트 | Linux | Windows | Mac | -| -------- | ----- | ------- | --- | -| sh | ✅ | ⌛ | ⌛ | -| ps | N/A | ⌛ | ⌛ | +| 운영 체제 | 상태 | +| --------- | ---- | +| Linux | ✅ | +| macOS | ⌛ | +| Windows | ⌛ | ## 문서 @@ -228,12 +249,6 @@ my-project/ **AI 어시스턴트 CLI를 찾을 수 없음:** `rpgkit check` 를 실행하고, 선택한 어시스턴트 CLI를 설치 및 인증한 다음 `rpgkit init` 또는 `rpgkit update` 를 다시 실행하세요. -**MCP 도구가 `rpg_unavailable` 을 보고함:** `/rpgkit.encode` 를 실행해 `.rpgkit/data/rpg.json` 을 생성하세요. - -**증분 업데이트 실패:** `.rpgkit/logs/update_rpg.log` 를 확인한 다음 `/rpgkit.update_rpg` 를 실행하세요. - -**속도 제한 또는 비공개 저장소 접근 권한으로 인해 템플릿 다운로드 실패:** `--github-token $GITHUB_TOKEN` 을 전달하거나 `GH_TOKEN` / `GITHUB_TOKEN` 을 설정하세요. - ## 라이선스 MIT License — 자세한 내용은 [LICENSE](LICENSE) 참조. diff --git a/RPG-Kit/README.md b/RPG-Kit/README.md index a8e9cb3..e1b4877 100644 --- a/RPG-Kit/README.md +++ b/RPG-Kit/README.md @@ -79,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit in action -Below is part of the graph visualization generated for this repository. Run `/rpgkit.encode` then `rpgkit view-graph` to open the full interactive graph in your browser (the underlying file is `/.rpgkit/reports/rpg.html`). +Below is part of the graph visualization generated for this repository. After running `/rpgkit.encode`, you can open `/.rpgkit/reports/rpg.html` to browse the full interactive graph. Run `rpgkit version` to see the resolved paths for the current workspace. ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -103,30 +103,7 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` -Since `0.1.3` the wheel ships the pipeline scripts and slash-command templates as packaged assets, so `rpgkit init` works offline (air-gapped, corporate proxy, etc.). To force the older GitHub-release-zip download path — e.g. to grab prompts newer than the installed CLI — pass `--legacy-download`. - -### Updating RPG-Kit - -Two flavours of update exist; pick the one that matches what you want to change. - -```bash -# (A) Refresh THIS workspace from the currently-installed CLI version -# (offline, fast, idempotent) -cd my-project -rpgkit update - -# (B) Upgrade the CLI itself, then refresh THIS workspace -# (network — uv / pipx / pip is auto-detected) -cd my-project -rpgkit update --pull - -# (C) Bypass the bundled assets and pull from the latest GitHub release zip -# (useful when a prompt fix has shipped but the CLI release has not yet) -cd my-project -rpgkit update --legacy-download -``` - -`rpgkit update` records the channel you chose in `~/.rpgkit/workspaces//.meta.toml` so subsequent runs default to the same source. Your edits to `/.rpgkit/config.toml` (see [`docs/configuration.md`](docs/configuration.md)) are preserved across updates. +Since `0.1.3`, the wheel ships the pipeline scripts and slash-command templates as packaged assets, so `rpgkit init` works offline (for example in air-gapped or corporate proxy environments). ## Quick Start: New Repository @@ -147,7 +124,6 @@ Use this path when you want RPG-Kit to turn requirements into a new codebase. ```bash rpgkit init my-project --ai claude --script sh rpgkit init my-project --ai copilot - rpgkit init my-project --github-token $GITHUB_TOKEN ``` 2. **[Optional]** place your requirement documents in `my-project/docs/`. @@ -170,7 +146,13 @@ Use this path when you want RPG-Kit to turn requirements into a new codebase. [Optional] /rpgkit.rpg_edit ``` -RPG-Kit progressively builds an `rpg.json` (under `~/.rpgkit/workspaces//data/`) and uses it to keep requirements, planning artifacts, generated code, and dependency information aligned. Runtime state lives outside your workspace so your git history stays clean; only `/.rpgkit/{config.toml,reports/}` belong in the repo. +> [!IMPORTANT] +> **Coding Agents are invoked slightly differently**: +> +> - **Claude Code**: type `/rpgkit.feature_spec ...` directly in the chat — slash commands are recognised and dispatch the matching workflow. +> - **GitHub Copilot CLI**: slash commands are not supported (custom agents are), so first run `/agent rpgkit.feature_spec` to switch to the target agent, then type `start` to run its built-in workflow. + +RPG-Kit progressively builds `rpg.json` in the home-side runtime directory (`~/.rpgkit/workspaces//data/rpg.json`) and uses it to keep requirements, planning artifacts, generated code, and dependency information aligned. Your workspace source files are not polluted. ## Quick Start: Existing Repository @@ -182,10 +164,8 @@ Use this path when you already have a repository and want an AI agent to underst 1. Initialize RPG-Kit in the repository root and build the initial graph: ```bash - mkdir my-project - cp -r existing-repo/ my-project/ - cd my-project - rpgkit init . --encode + cd existing-repo/ + rpgkit init . --encode # --encode builds the RPG from the current code ``` If you want to skip the confirmation prompt for a non-empty directory: @@ -196,7 +176,7 @@ Use this path when you already have a repository and want an AI agent to underst 2. Launch your AI coding agent in the repository. -3. Use the generated RPG through MCP tools and slash commands: +3. **[Optional]** Use the generated RPG through MCP tools and slash commands. The following commands are only needed when run manually: ```text /rpgkit.encode # rebuild the full RPG when needed @@ -204,37 +184,53 @@ Use this path when you already have a repository and want an AI agent to underst /rpgkit.rpg_edit # graph-aware code edit ``` -4. After commits, RPG-Kit hooks keep the workspace's `rpg.json`, `dep_graph.json` (under `~/.rpgkit/workspaces//data/`) and the user-facing `rpg.html` (under `/.rpgkit/reports/`) aligned with code changes. If the hook fails or is skipped, run `/rpgkit.update_rpg`. +4. After each commit, the git hook installed by RPG-Kit automatically calls the `rpgkit hook ` dispatcher to update the RPG and keep it aligned with code changes. If the hook fails or is skipped, run `/rpgkit.update_rpg` manually. ## What happens after `rpgkit init` -`rpgkit init` does not modify your source files. It adds command definitions, runtime scripts, MCP configuration, and generated graph data alongside your code. +`rpgkit init` does not modify your source files, **and it does not write runtime state into your workspace**. It only adds command definitions, MCP configuration, and hooks to your workspace. RPG-Kit runtime data (artifacts and logs) lives under the home-side directory `~/.rpgkit/workspaces//`, isolated by a hash derived from the workspace's absolute path. ```text my-project/ ├── docs/ # Optional requirement docs for /rpgkit.feature_spec -├── .github/ or .claude/ # AI assistant command definitions and settings +├── .github/ or .claude/ # Coding Agent command definitions and settings ├── .vscode/ # Copilot/VS Code MCP configuration when applicable -└── .rpgkit/ # RPG-Kit runtime - ├── scripts/ # Pipeline scripts and support packages - ├── data/ # Generated artifacts, including rpg.json and dep_graph.json - ├── logs/ # Per-stage execution logs - └── reports/ # Review and diagnostic reports when generated +├── .rpgkit/ # Generated reports and configuration files +└── .git/hooks/ # post-commit / post-merge installed by rpgkit init (each hook is one line: `rpgkit hook `) ``` See [docs/project-structure.md](docs/project-structure.md) for the full layout and data file reference. +## Updating RPG-Kit + +```bash +uv tool install rpgkit-cli \ + --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" \ + --force \ + --reinstall + +# Update an existing workspace +cd +rpgkit update +``` + ## Supported Platforms -| Platform | Claude Code | GitHub Copilot | Codex | -| ----------------------- | ----------- | -------------- | ----- | -| CLI usage | ✅ | ✅ (No MCP) | ⌛ | -| VS Code extension usage | ✅ | ✅ | ⌛ | +**Coding Agent support**: -| Script | Linux | Windows | Mac | -| ------ | ----- | ------- | --- | -| sh | ✅ | ⌛ | ⌛ | -| ps | N/A | ⌛ | ⌛ | +| Agent | CLI usage | VS Code extension usage | +| -------------- | --------- | ----------------------- | +| Claude Code | ✅ | ✅ | +| GitHub Copilot | ✅ | ✅ | +| Codex | ⌛ | ⌛ | + +**Operating system support**: + +| Operating system | Status | +| ---------------- | ------ | +| Linux | ✅ | +| macOS | ⌛ | +| Windows | ⌛ | ## Documentation @@ -253,12 +249,6 @@ See [docs/project-structure.md](docs/project-structure.md) for the full layout a **AI assistant CLI not found:** run `rpgkit check`, install and authenticate the selected assistant CLI, then rerun `rpgkit init` or `rpgkit update`. -**MCP tools report `rpg_unavailable`:** run `/rpgkit.encode` to create the workspace's `rpg.json` (under `~/.rpgkit/workspaces//data/`). - -**Incremental update failed:** inspect `~/.rpgkit/workspaces//logs/update_rpg.log`, then run `/rpgkit.update_rpg`. - -**Template download fails due to rate limits or private repo access:** pass `--github-token $GITHUB_TOKEN` or set `GH_TOKEN` / `GITHUB_TOKEN`. - ## License MIT License - See [LICENSE](LICENSE) for details. diff --git a/RPG-Kit/README.zh-CN.md b/RPG-Kit/README.zh-CN.md index 2d29852..5a5bf65 100644 --- a/RPG-Kit/README.zh-CN.md +++ b/RPG-Kit/README.zh-CN.md @@ -8,7 +8,6 @@ हिन्दी

- ## 让编码智能体先规划,再编辑 编码智能体擅长局部编辑,但仓库级任务如果缺少稳定的规划结构往往会失败:需求漂移、架构决策丢失、多文件生成前后不一致、更新可能错过隐藏依赖。 @@ -36,7 +35,6 @@ RPG-Kit 为 Claude Code 和 GitHub Copilot 提供一个面向仓库级编码的*
完整的命令级工作流图 - ```text Forward Direction: Requirements → RPG → Code @@ -81,7 +79,7 @@ MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ### RPG-Kit 实际效果 -下图是为本仓库生成的图可视化的一部分。运行 `/rpgkit.encode`,然后打开 `.rpgkit/reports/rpg.html` 浏览完整的交互式图。 +下图是为本仓库生成的图可视化的一部分。运行 `/rpgkit.encode` 后,可以打开 `/.rpgkit/reports/rpg.html` 浏览完整的交互式图。运行 `rpgkit version` 可以看到当前工作区的具体路径。 ![RPG-Kit repository graph visualization](../docs/rpgkit_visualized_graph.png) @@ -105,6 +103,8 @@ rpgkit check uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" rpgkit init ``` +从 `0.1.3` 开始,wheel 会把 pipeline scripts 和 slash-command templates 作为打包资源一起发布,因此 `rpgkit init` 可以离线工作(例如 air-gapped 环境、公司代理环境等)。 + ## 快速开始:新仓库 当你希望 RPG-Kit 把需求转换为新代码库时,使用此路径。 @@ -146,7 +146,11 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K [Optional] /rpgkit.rpg_edit ``` - 注意:copilot不支持自定义命令,需要先 /agent 切换到指定agent,然后输入 start 以开始运行 +> [!IMPORTANT] +> **不同 Coding Agent 的调用方式略有不同**: +> +> - **Claude Code**:直接在对话中输入 `/rpgkit.feature_spec ...`,slash command 会被识别并触发对应 workflow。 +> - **GitHub Copilot CLI**:不支持 slash command(但支持自定义 agent),需要先 `/agent rpgkit.feature_spec` 切换到目标 agent,然后输入 `start` 让它执行内置的 workflow。 RPG-Kit 会渐进式地在 home-side 运行时目录(`~/.rpgkit/workspaces//data/rpg.json`)里创建 `rpg.json`,并用它把需求、规划产物、生成的代码和依赖信息保持对齐。你的工作区源文件不会被污染。 @@ -160,9 +164,8 @@ RPG-Kit 会渐进式地在 home-side 运行时目录(`~/.rpgkit/workspaces/ # 图感知的代码编辑 ``` -4. 每次 commit 后,RPG-Kit 安装的 git hook 会自动调用 `rpgkit hook ` 调度器,更新RPG,与代码变更保持对齐。如果 hook 失败或被跳过,可以手动运行 `/rpgkit.update_rpg`。 +4. 每次 commit 后,RPG-Kit 安装的 git hook 会自动调用 `rpgkit hook ` 调度器,更新 RPG,与代码变更保持对齐。如果 hook 失败或被跳过,可以手动运行 `/rpgkit.update_rpg`。 ## `rpgkit init` 之后会发生什么 -`rpgkit init` 不会修改你的源文件,**也不会在你的工作区写入运行时状态**。它只在你的工作区添加命令定义、MCP 配置和 hooks,所有 RPG-Kit 的运行时数据(脚本、产物、日志、报告)都放在 home-side 目录 `~/.rpgkit/workspaces//` 下,由工作区绝对路径派生的 hash 隔离。 +`rpgkit init` 不会修改你的源文件,**也不会在你的工作区写入运行时状态**。它只在你的工作区添加命令定义、MCP 配置和 hooks,所有 RPG-Kit 的运行时数据(产物、日志)都放在 home-side 目录 `~/.rpgkit/workspaces//` 下,由工作区绝对路径派生的 hash 隔离。 ```text my-project/ ├── docs/ # /rpgkit.feature_spec 的可选需求文档 ├── .github/ or .claude/ # AI 助手的命令定义和设置 ├── .vscode/ # 适用时的 Copilot/VS Code MCP 配置 -├── .rpgkit/ # 包含生成的报告 和 配置文件 +├── .rpgkit/ # 包含生成的报告和配置文件 +└── .git/hooks/ # rpgkit init 装的 post-commit / post-merge(每个 hook 仅一行:`rpgkit hook `) ``` 完整的目录布局和数据文件参考见 [docs/project-structure.md](docs/project-structure.md)。 +## 更新 RPG-Kit + +```bash +uv tool install rpgkit-cli \ + --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-Kit" \ + --force \ + --reinstall + +# 对已有工作区进行更新 +cd +rpgkit update +``` + ## 支持的平台 **Coding Agent 支持**: @@ -238,4 +255,4 @@ MIT License —— 详情见 [LICENSE](LICENSE)。 ## 致谢 -基于 [GitHub Spec-Kit](https://github.com/github/spec-kit)。 \ No newline at end of file +基于 [GitHub Spec-Kit](https://github.com/github/spec-kit)。 From c9cb8899b98aba4b7ac90e7707e7a7a679ffc119 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 16:59:43 +0800 Subject: [PATCH 27/31] fix(rpgkit): address Copilot PR #56 review feedback - _storage.find_workspace_root_from: drop home-side dir guard so fresh marker-only workspaces are discovered before any state is written; rely on .meta.toml workspace_path for stale-marker protection (moved/renamed dirs). Add regression test. - scripts/common/rpg_io: strip GIT_INDEX_FILE / GIT_DIR / GIT_WORK_TREE / GIT_OBJECT_DIRECTORY from env before running inner-git recovery subprocesses so hook contexts don't bleed the outer repo's git state into recovery queries. - scripts/__init__.py: refresh docstring to describe the 'rpgkit script ' dispatcher path (no longer copied into the workspace). - entries.mcp_main: remove unreachable 'scripts_dir is None' branch (_assets.scripts_dir always returns Path). - templates/commands/feature_build.md: rewrite broken / duplicated Step 2 sentence into a single coherent paragraph. - docs/project-structure.md: drop retired 'pre-commit' from the installed-hooks quick-reference row. --- RPG-Kit/docs/project-structure.md | 2 +- RPG-Kit/scripts/__init__.py | 5 +-- RPG-Kit/scripts/common/rpg_io.py | 10 +++++- RPG-Kit/src/rpgkit_cli/_storage.py | 37 +++++++++------------ RPG-Kit/src/rpgkit_cli/entries.py | 2 +- RPG-Kit/templates/commands/feature_build.md | 5 ++- RPG-Kit/tests/test_storage.py | 20 +++++++++++ 7 files changed, 52 insertions(+), 29 deletions(-) diff --git a/RPG-Kit/docs/project-structure.md b/RPG-Kit/docs/project-structure.md index 160ef83..34c829f 100644 --- a/RPG-Kit/docs/project-structure.md +++ b/RPG-Kit/docs/project-structure.md @@ -81,7 +81,7 @@ Command definitions are installed into the AI-agent-specific folder. Normal user | User-facing HTML reports (`rpg.html`, …) | `/.rpgkit/reports/` | | Agent command definitions | `/.claude/` or `/.github/` | | MCP / VS Code config | `/.vscode/` | -| Git hooks (`pre-commit`, `post-commit`, `post-merge`) | `/.git/hooks/` | +| Git hooks (`post-commit`, `post-merge`) | `/.git/hooks/` | | Generated data (`rpg.json`, `dep_graph.json`, …) | `~/.rpgkit/workspaces//data/` | | Per-stage logs | `~/.rpgkit/workspaces//logs/` | | Inner-git snapshot repo | `~/.rpgkit/workspaces//.git/` | diff --git a/RPG-Kit/scripts/__init__.py b/RPG-Kit/scripts/__init__.py index 7152d72..1c7afd0 100644 --- a/RPG-Kit/scripts/__init__.py +++ b/RPG-Kit/scripts/__init__.py @@ -11,6 +11,7 @@ # At runtime ``scripts/`` is NOT imported as ``rpgkit_cli.scripts`` # — the wheel's ``force-include`` rewrites the install target to # ``rpgkit_cli/core_pack/scripts/``, and that path is also not imported -# as a Python module. Callers always copy scripts into the user's -# workspace and invoke them with ``python /.rpgkit/scripts/.py``. +# as a Python module. Scripts are executed directly from the packaged +# location via the ``rpgkit script `` dispatcher, which resolves +# them through ``rpgkit_cli._assets.scripts_dir()``. # diff --git a/RPG-Kit/scripts/common/rpg_io.py b/RPG-Kit/scripts/common/rpg_io.py index e29e24f..9297ffa 100644 --- a/RPG-Kit/scripts/common/rpg_io.py +++ b/RPG-Kit/scripts/common/rpg_io.py @@ -208,7 +208,15 @@ def _try_restore_from_inner_git( return None # Force English git messages (consistent with _inner_git.py). - env = {**os.environ, "LC_ALL": "C", "LANG": "C"} + # Strip any inherited ``GIT_*`` vars (e.g. ``GIT_DIR``, + # ``GIT_INDEX_FILE``) that would point ``git`` at the **outer** + # repository when this recovery runs inside a hook context. This + # mirrors the env-sanitisation done in ``rpgkit_cli._inner_git._run_git``. + env = {k: v for k, v in os.environ.items() + if k not in ("GIT_INDEX_FILE", "GIT_DIR", + "GIT_WORK_TREE", "GIT_OBJECT_DIRECTORY")} + env["LC_ALL"] = "C" + env["LANG"] = "C" # Walk linear history (most recent first). ``--follow`` keeps # working when a script ever renames data files in the future. diff --git a/RPG-Kit/src/rpgkit_cli/_storage.py b/RPG-Kit/src/rpgkit_cli/_storage.py index a6d39a4..afc22fe 100644 --- a/RPG-Kit/src/rpgkit_cli/_storage.py +++ b/RPG-Kit/src/rpgkit_cli/_storage.py @@ -196,24 +196,18 @@ def workspace_reports_dir(workspace_path: Path) -> Path: def _is_live_workspace_root(root: Path) -> bool: """Return True iff a candidate workspace root is still live. - A bare ``.rpgkit/config.toml`` isn't sufficient on its own: when - a user deletes (or moves) a workspace, the marker file may linger - on a parent directory whose home-side storage no longer matches. - Without this guard, :func:`find_workspace_root_from` would happily - climb into the stale parent and silently misroute reads/writes - (e.g. a freshly-stripped subdir would inherit the grandparent's - inner-git history). - - The check is intentionally minimal and side-effect-free: - - 1. ``~/.rpgkit/workspaces//`` exists — guards against - "home-side pruned" scenarios. - 2. If ``.meta.toml`` exists, ``workspace_path`` matches ``root`` — - guards against "directory was moved/renamed" scenarios where - the stale marker still points at the old absolute path. + A bare ``.rpgkit/config.toml`` is enough for a *fresh* workspace + (the marker may be planted before any home-side state is written), + so the marker alone is treated as live until proven stale. + + Staleness is detected only when ``.meta.toml`` is present: a moved + or renamed workspace records its original absolute path there, and + if that recorded path no longer matches the candidate directory the + marker is treated as stale. This guards :func:`find_workspace_root_from` + against climbing into a renamed parent and misrouting reads/writes, + while still allowing brand-new (marker-only) workspaces to be + discovered before they have any home-side state. """ - if not home_workspace_dir(root).is_dir(): - return False meta = read_meta(root) if meta is not None: recorded = meta.get("workspace_path") @@ -227,10 +221,11 @@ def find_workspace_root_from(start: Optional[Path] = None) -> Optional[Path]: A directory qualifies as a workspace if it contains ``.rpgkit/config.toml`` (see :data:`WORKSPACE_MARKER_RELPATH`) - **and** passes :func:`_is_live_workspace_root` — i.e. its home-side - storage still exists and the recorded path matches. Stale markers - on parent directories are skipped, so the walker continues climbing - rather than misrouting into a different workspace's state. + **and** passes :func:`_is_live_workspace_root` — i.e. either it + has no ``.meta.toml`` (fresh workspace), or the recorded + ``workspace_path`` in meta still matches. Stale (moved/renamed) + markers on parent directories are skipped, so the walker continues + climbing rather than misrouting into a different workspace's state. Returns the **resolved** path of the workspace root, or ``None`` when no live marker is found before reaching the filesystem root. diff --git a/RPG-Kit/src/rpgkit_cli/entries.py b/RPG-Kit/src/rpgkit_cli/entries.py index c851d7e..83c3d04 100644 --- a/RPG-Kit/src/rpgkit_cli/entries.py +++ b/RPG-Kit/src/rpgkit_cli/entries.py @@ -24,7 +24,7 @@ def mcp_main() -> None: os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1") scripts_dir = _assets.scripts_dir() - if scripts_dir is None or not scripts_dir.is_dir(): + if not scripts_dir.is_dir(): sys.stderr.write( "rpgkit-mcp: packaged scripts directory unavailable. " "Try reinstalling: `uv tool install rpgkit-cli --force`.\n" diff --git a/RPG-Kit/templates/commands/feature_build.md b/RPG-Kit/templates/commands/feature_build.md index 569d313..c035fe6 100644 --- a/RPG-Kit/templates/commands/feature_build.md +++ b/RPG-Kit/templates/commands/feature_build.md @@ -65,9 +65,8 @@ The script automatically detects whether the output file (`feature_build.json`) ``` The script prints its full output on stdout and also writes a - The script writes a structured log automatically. - Inspect the stdout to capture the `FEATURE EXPANSION SUMMARY` - section described below. + structured log automatically. Inspect the stdout to capture the + `FEATURE EXPANSION SUMMARY` section described below. **Available parameters for Step 2:** diff --git a/RPG-Kit/tests/test_storage.py b/RPG-Kit/tests/test_storage.py index ae8eff9..fe20061 100644 --- a/RPG-Kit/tests/test_storage.py +++ b/RPG-Kit/tests/test_storage.py @@ -133,6 +133,26 @@ def test_default_start_is_cwd( monkeypatch.chdir(sub) assert _storage.find_workspace_root_from() == workspace.resolve() + def test_skips_stale_marker_with_mismatched_meta( + self, fake_home: Path, workspace: Path + ) -> None: + """A ``.meta.toml`` whose ``workspace_path`` doesn't match the + marker's directory is treated as stale (e.g. dir was moved or + renamed) and the walker keeps climbing rather than misrouting.""" + self._mark(workspace) + # Forge meta recording a *different* absolute path under + # ``~/.rpgkit/workspaces//.meta.toml``. + meta_path = _storage.workspace_meta_path(workspace) + meta_path.parent.mkdir(parents=True, exist_ok=True) + meta_path.write_text( + 'channel = "bundle"\n' + f'workspace_path = "{workspace.parent / "elsewhere"}"\n' + 'rpgkit_cli_version_at_init = "0.1.4"\n' + 'rpgkit_cli_version_last_seen = "0.1.4"\n' + 'initialised_at = "2026-01-01T00:00:00+00:00"\n' + ) + assert _storage.find_workspace_root_from(workspace) is None + # --------------------------------------------------------------------------- # .meta.toml read / write From 3abb5b281e5d05342b63e03f7b70d42f8da1bc48 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 18:11:11 +0800 Subject: [PATCH 28/31] fix(rpgkit): address Copilot PR #56 follow-up review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/common/rpg_io.py: add `--follow` to inner-git `git log` recovery call so the rename-tracking promise in the surrounding comment is actually delivered (works because the call uses a single path) - src/rpgkit_cli/_inner_git.py: correct `ensure_inner_git` docstring to say the dropped `.gitignore` excludes `logs/copilot/` (not `logs/`), matching the actual `_INNER_GIT_IGNORE` content and the file's own earlier comment - tests/test_e2e.py: drop `test_cli_encode_helpers` — it was a misnamed path-constant shape check (no encode helpers exercised, fixtures unused) whose responsibilities are already covered by tests/test_storage.py --- RPG-Kit/scripts/common/rpg_io.py | 3 ++- RPG-Kit/src/rpgkit_cli/_inner_git.py | 6 ++++-- RPG-Kit/tests/test_e2e.py | 12 ------------ 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/RPG-Kit/scripts/common/rpg_io.py b/RPG-Kit/scripts/common/rpg_io.py index 9297ffa..7bf4213 100644 --- a/RPG-Kit/scripts/common/rpg_io.py +++ b/RPG-Kit/scripts/common/rpg_io.py @@ -222,7 +222,8 @@ def _try_restore_from_inner_git( # working when a script ever renames data files in the future. try: log = subprocess.run( - ["git", "-C", str(git_dir), "log", "--format=%H", "--", relpath], + ["git", "-C", str(git_dir), "log", "--follow", + "--format=%H", "--", relpath], capture_output=True, text=True, env=env, timeout=10, ) except (subprocess.SubprocessError, OSError): diff --git a/RPG-Kit/src/rpgkit_cli/_inner_git.py b/RPG-Kit/src/rpgkit_cli/_inner_git.py index 02f1810..7193689 100644 --- a/RPG-Kit/src/rpgkit_cli/_inner_git.py +++ b/RPG-Kit/src/rpgkit_cli/_inner_git.py @@ -232,8 +232,10 @@ def ensure_inner_git(workspace: Path, *, initial_msg: Optional[str] = None) -> b which is information only the caller has. When a fresh repo is created we also drop a ``.gitignore`` that - excludes ``logs/`` (too noisy), then commit the current state of - ``data/`` + ``.meta.toml`` so ``git log`` has a starting point. + excludes ``logs/copilot/`` (LLM session traces — large, not useful + in history; see :data:`_INNER_GIT_IGNORE`), then commit the current + state of ``data/`` + ``.meta.toml`` so ``git log`` has a starting + point. """ home_dir = _inner_git_dir(workspace) if not home_dir.is_dir(): diff --git a/RPG-Kit/tests/test_e2e.py b/RPG-Kit/tests/test_e2e.py index cf0ce1c..da3bda0 100644 --- a/RPG-Kit/tests/test_e2e.py +++ b/RPG-Kit/tests/test_e2e.py @@ -728,18 +728,6 @@ def send_sms(to: str, message: str): class TestE2ECLISimulation: """Simulate CLI-like invocations end-to-end.""" - def test_cli_encode_helpers(self, sample_repo, tmp_path): - """RPG_FILE path constant points to the home-dir runtime location. - - Workspace state lives under ``~/.rpgkit/workspaces//``, - so ``RPG_FILE`` resolves to ``/data/rpg.json`` rather than the - legacy ``/.rpgkit/data/rpg.json``. We just assert the trailing - path components so the test is independent of any specific hash. - """ - from common.paths import RPG_FILE - - assert str(RPG_FILE).endswith(os.path.join("data", "rpg.json")) - def test_cli_rpg_stats(self, encoded_rpg): """check_encode.get_rpg_stats produces valid statistics from encoded RPG data.""" from rpg_encoder.check_encode import get_rpg_stats From c07d9f1cc8e1949d3de6f052463b30fadb4d0674 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 20:31:11 +0800 Subject: [PATCH 29/31] feat(rpgkit): slug-based workspace ids with overflow hash suffix Replace the opaque 12-hex-char SHA-256 workspace id with a readable slug derived from the resolved absolute path (e.g. 'home-hys-projects-myrepo'), matching the convention used by Claude Code under ~/.claude/projects/. When the slug would exceed 200 chars (NAME_MAX safety margin) it is truncated to 193 chars and suffixed with '-' plus a 6-char base36 SHA-256 digest, so deep paths still produce a deterministic, unique directory name. Backward compatibility: home_workspace_dir() honours a pre-0.1.4 12-hex-char directory if one already exists on disk, so existing workspaces keep resolving to their current storage. Also sweeps stale '' placeholders out of user-facing docs, CLI prompts, docstrings, and comments, replacing them with the documented '' term. --- RPG-Kit/README.hi-IN.md | 4 +- RPG-Kit/README.ja-JP.md | 4 +- RPG-Kit/README.ko-KR.md | 4 +- RPG-Kit/README.md | 4 +- RPG-Kit/README.zh-CN.md | 4 +- RPG-Kit/docs/cli-reference.md | 2 +- RPG-Kit/docs/project-structure.md | 30 ++--- RPG-Kit/scripts/common/paths.py | 12 +- RPG-Kit/scripts/common/rpg_io.py | 8 +- RPG-Kit/scripts/feature_spec_to_json.py | 2 +- RPG-Kit/scripts/mcp_server.py | 2 +- RPG-Kit/scripts/rpg_edit/save_plan.py | 2 +- RPG-Kit/scripts/rpg_encoder/run_encode.py | 2 +- RPG-Kit/src/rpgkit_cli/__init__.py | 42 +++--- RPG-Kit/src/rpgkit_cli/_inner_git.py | 8 +- RPG-Kit/src/rpgkit_cli/_storage.py | 155 ++++++++++++++++++---- RPG-Kit/tests/test_rpg_io.py | 2 +- RPG-Kit/tests/test_storage.py | 79 ++++++++++- 18 files changed, 273 insertions(+), 93 deletions(-) diff --git a/RPG-Kit/README.hi-IN.md b/RPG-Kit/README.hi-IN.md index 60684a5..86b24fc 100644 --- a/RPG-Kit/README.hi-IN.md +++ b/RPG-Kit/README.hi-IN.md @@ -152,7 +152,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K > - **Claude Code**: चैट में सीधे `/rpgkit.feature_spec ...` टाइप करें — slash command पहचाने जाते हैं और संबंधित workflow ट्रिगर हो जाता है। > - **GitHub Copilot CLI**: slash command समर्थित नहीं हैं (कस्टम agent समर्थित हैं), इसलिए पहले `/agent rpgkit.feature_spec` से लक्ष्य agent पर स्विच करें, फिर `start` टाइप करके इसका अंतर्निहित workflow चलाएँ। -RPG-Kit क्रमिक रूप से `~/.rpgkit/workspaces//data/rpg.json` बनाता है और इसका उपयोग आवश्यकताओं, प्लानिंग आउटपुट, जनरेटेड कोड और dependency जानकारी को संरेखित रखने के लिए करता है। आपके वर्कस्पेस की स्रोत फ़ाइलें दूषित नहीं होंगी। +RPG-Kit क्रमिक रूप से `~/.rpgkit/workspaces//data/rpg.json` बनाता है और इसका उपयोग आवश्यकताओं, प्लानिंग आउटपुट, जनरेटेड कोड और dependency जानकारी को संरेखित रखने के लिए करता है। आपके वर्कस्पेस की स्रोत फ़़ाइलें दूषित नहीं होंगी। ## Quick Start: मौजूदा रिपॉज़िटरी @@ -188,7 +188,7 @@ RPG-Kit क्रमिक रूप से `~/.rpgkit/workspaces//data/rp ## `rpgkit init` के बाद क्या होता है -`rpgkit init` आपकी स्रोत फ़ाइलों को संशोधित नहीं करता है, **और आपके वर्कस्पेस में रनटाइम स्टेट नहीं लिखता है**। यह आपके वर्कस्पेस में केवल command definitions, MCP कॉन्फ़िगरेशन और hooks जोड़ता है। RPG-Kit का रनटाइम डेटा (outputs और logs) home-side निर्देशिका `~/.rpgkit/workspaces//` के अंतर्गत रखा जाता है, जो वर्कस्पेस के absolute path से जनित hash द्वारा अलग किया जाता है। +`rpgkit init` आपकी स्रोत फ़़ाइलों को संशोधित नहीं करता है, **और आपके वर्कस्पेस में रनटाइम स्टेट नहीं लिखता है**। यह आपके वर्कस्पेस में केवल command definitions, MCP कॉन्फ़़िगरेशन और hooks जोड़ता है। RPG-Kit का रनटाइम डेटा (outputs और logs) home-side निर्देशिका `~/.rpgkit/workspaces//` के अंतर्गत रखा जाता है, जहाँ `` वर्कस्पेस के absolute path से जनित एक पठनीय slug है (उदाहरण: `home-hys-projects-myrepo`)। ```text my-project/ diff --git a/RPG-Kit/README.ja-JP.md b/RPG-Kit/README.ja-JP.md index ddbee1e..6da085b 100644 --- a/RPG-Kit/README.ja-JP.md +++ b/RPG-Kit/README.ja-JP.md @@ -152,7 +152,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K > - **Claude Code**:チャットにそのまま `/rpgkit.feature_spec ...` と入力します。slash command が認識され、対応する workflow がトリガーされます。 > - **GitHub Copilot CLI**:slash command はサポートされません(カスタム agent はサポート)。まず `/agent rpgkit.feature_spec` で目的の agent に切り替え、その後 `start` と入力して内蔵の workflow を実行します。 -RPG-Kit は `~/.rpgkit/workspaces//data/rpg.json` を段階的に作成し、それを使って要件・計画成果物・生成コード・依存情報を整合した状態に保ちます。ワークスペースのソースファイルは汚染されません。 +RPG-Kit は `~/.rpgkit/workspaces//data/rpg.json` を段階的に作成し、それを使って要件・計画成果物・生成コード・依存情報を整合した状態に保ちます。ワークスペースのソースファイルは汚染されません。 ## クイックスタート: 既存リポジトリ @@ -188,7 +188,7 @@ RPG-Kit は `~/.rpgkit/workspaces//data/rpg.json` を段階的に作成し ## `rpgkit init` の後に起きること -`rpgkit init` はソースファイルを変更しません。また、**ワークスペースにランタイム状態を書き込みません**。ワークスペースには command 定義、MCP 設定、および hooks のみを追加します。RPG-Kit のランタイムデータ(成果物、ログ)は home-side ディレクトリ `~/.rpgkit/workspaces//` 下に配置され、ワークスペースの絶対パスから派生した hash で隔離されます。 +`rpgkit init` はソースファイルを変更しません。また、**ワークスペースにランタイム状態を書き込みません**。ワークスペースには command 定義、MCP 設定、および hooks のみを追加します。RPG-Kit のランタイムデータ(成果物、ログ)は home-side ディレクトリ `~/.rpgkit/workspaces//` 下に配置されます。`` はワークスペースの絶対パスから導出される可読な slug です(例: `home-hys-projects-myrepo`)。 ```text my-project/ diff --git a/RPG-Kit/README.ko-KR.md b/RPG-Kit/README.ko-KR.md index 39e9dfa..c770511 100644 --- a/RPG-Kit/README.ko-KR.md +++ b/RPG-Kit/README.ko-KR.md @@ -152,7 +152,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K > - **Claude Code**: 채팅에 직접 `/rpgkit.feature_spec ...` 을 입력하면 slash command가 인식되어 해당 workflow가 트리거됩니다. > - **GitHub Copilot CLI**: slash command는 지원하지 않으나(커스텀 agent는 지원), 먼저 `/agent rpgkit.feature_spec` 으로 대상 agent로 전환한 다음 `start` 를 입력해 내장된 workflow를 실행합니다. -RPG-Kit은 `~/.rpgkit/workspaces//data/rpg.json` 을 점진적으로 생성하고, 이를 사용해 요구사항, 계획 산출물, 생성된 코드, 의존성 정보를 정합 상태로 유지합니다. 워크스페이스의 소스 파일은 오염되지 않습니다. +RPG-Kit은 `~/.rpgkit/workspaces//data/rpg.json` 을 점진적으로 생성하고, 이를 사용해 요구사항, 계획 산출물, 생성된 코드, 의존성 정보를 정합 상태로 유지합니다. 워크스페이스의 소스 파일은 오염되지 않습니다. ## Quick Start: 기존 저장소 @@ -188,7 +188,7 @@ RPG-Kit은 `~/.rpgkit/workspaces//data/rpg.json` 을 점진적으로 생 ## `rpgkit init` 이후 일어나는 일 -`rpgkit init` 은 소스 파일을 수정하지 않습니다. 또한 **워크스페이스에 런타임 상태를 기록하지도 않습니다**. 워크스페이스에는 command 정의, MCP 구성, hooks만 추가합니다. RPG-Kit의 런타임 데이터(산출물, 로그)는 home-side 디렉터리 `~/.rpgkit/workspaces//` 아래에 배치되며, 워크스페이스 절대 경로에서 파생된 hash로 격리됩니다. +`rpgkit init` 은 소스 파일을 수정하지 않습니다. 또한 **워크스페이스에 런타임 상태를 기록하지도 않습니다**. 워크스페이스에는 command 정의, MCP 구성, hooks만 추가합니다. RPG-Kit의 런타임 데이터(산출물, 로그)는 home-side 디렉터리 `~/.rpgkit/workspaces//` 아래에 배치되며, `` 는 워크스페이스의 절대 경로에서 파생된 가독성 있는 slug입니다 (예: `home-hys-projects-myrepo`). ```text my-project/ diff --git a/RPG-Kit/README.md b/RPG-Kit/README.md index e1b4877..ea45078 100644 --- a/RPG-Kit/README.md +++ b/RPG-Kit/README.md @@ -152,7 +152,7 @@ Use this path when you want RPG-Kit to turn requirements into a new codebase. > - **Claude Code**: type `/rpgkit.feature_spec ...` directly in the chat — slash commands are recognised and dispatch the matching workflow. > - **GitHub Copilot CLI**: slash commands are not supported (custom agents are), so first run `/agent rpgkit.feature_spec` to switch to the target agent, then type `start` to run its built-in workflow. -RPG-Kit progressively builds `rpg.json` in the home-side runtime directory (`~/.rpgkit/workspaces//data/rpg.json`) and uses it to keep requirements, planning artifacts, generated code, and dependency information aligned. Your workspace source files are not polluted. +RPG-Kit progressively builds `rpg.json` in the home-side runtime directory (`~/.rpgkit/workspaces//data/rpg.json`) and uses it to keep requirements, planning artifacts, generated code, and dependency information aligned. Your workspace source files are not polluted. ## Quick Start: Existing Repository @@ -188,7 +188,7 @@ Use this path when you already have a repository and want an AI agent to underst ## What happens after `rpgkit init` -`rpgkit init` does not modify your source files, **and it does not write runtime state into your workspace**. It only adds command definitions, MCP configuration, and hooks to your workspace. RPG-Kit runtime data (artifacts and logs) lives under the home-side directory `~/.rpgkit/workspaces//`, isolated by a hash derived from the workspace's absolute path. +`rpgkit init` does not modify your source files, **and it does not write runtime state into your workspace**. It only adds command definitions, MCP configuration, and hooks to your workspace. RPG-Kit runtime data (artifacts and logs) lives under the home-side directory `~/.rpgkit/workspaces//`, where `` is a slug derived from the workspace's absolute path (e.g. `home-hys-projects-myrepo`). ```text my-project/ diff --git a/RPG-Kit/README.zh-CN.md b/RPG-Kit/README.zh-CN.md index 5a5bf65..90ffcdb 100644 --- a/RPG-Kit/README.zh-CN.md +++ b/RPG-Kit/README.zh-CN.md @@ -152,7 +152,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=RPG-K > - **Claude Code**:直接在对话中输入 `/rpgkit.feature_spec ...`,slash command 会被识别并触发对应 workflow。 > - **GitHub Copilot CLI**:不支持 slash command(但支持自定义 agent),需要先 `/agent rpgkit.feature_spec` 切换到目标 agent,然后输入 `start` 让它执行内置的 workflow。 -RPG-Kit 会渐进式地在 home-side 运行时目录(`~/.rpgkit/workspaces//data/rpg.json`)里创建 `rpg.json`,并用它把需求、规划产物、生成的代码和依赖信息保持对齐。你的工作区源文件不会被污染。 +RPG-Kit 会渐进式地在 home-side 运行时目录(`~/.rpgkit/workspaces//data/rpg.json`)里创建 `rpg.json`,并用它把需求、规划产物、生成的代码和依赖信息保持对齐。你的工作区源文件不会被污染。 ## 快速开始:已有仓库 @@ -188,7 +188,7 @@ RPG-Kit 会渐进式地在 home-side 运行时目录(`~/.rpgkit/workspaces//` 下,由工作区绝对路径派生的 hash 隔离。 +`rpgkit init` 不会修改你的源文件,**也不会在你的工作区写入运行时状态**。它只在你的工作区添加命令定义、MCP 配置和 hooks,所有 RPG-Kit 的运行时数据(产物、日志)都放在 home-side 目录 `~/.rpgkit/workspaces//` 下,其中 `` 是根据工作区绝对路径生成的可读 slug(例如 `home-hys-projects-myrepo`)。 ```text my-project/ diff --git a/RPG-Kit/docs/cli-reference.md b/RPG-Kit/docs/cli-reference.md index d46951e..397db67 100644 --- a/RPG-Kit/docs/cli-reference.md +++ b/RPG-Kit/docs/cli-reference.md @@ -96,7 +96,7 @@ Since `0.1.3`, `rpgkit init` and `rpgkit update` provision from two channels: | Packaged assets (bundle) | Default. Pulled from `rpgkit_cli/core_pack/` inside the installed wheel | No | | GitHub release zip (legacy) | `--legacy-download`, `--pre`, or `--script ps`, or when the bundle is unavailable (e.g. editable installs) | Yes | -`rpgkit init` records the chosen channel (`bundle` or `legacy`) in `~/.rpgkit/workspaces//.meta.toml` so subsequent `rpgkit update` invocations default to the same channel. Override with the flag of your choice at any time. +`rpgkit init` records the chosen channel (`bundle` or `legacy`) in `~/.rpgkit/workspaces//.meta.toml` so subsequent `rpgkit update` invocations default to the same channel. Override with the flag of your choice at any time. Verify that required tools are installed. diff --git a/RPG-Kit/docs/project-structure.md b/RPG-Kit/docs/project-structure.md index 34c829f..5685770 100644 --- a/RPG-Kit/docs/project-structure.md +++ b/RPG-Kit/docs/project-structure.md @@ -6,7 +6,7 @@ RPG-Kit installs alongside your project code: the directory you run `rpgkit init - `rpgkit init my-project` creates `my-project/` containing both your source code (`src/`, `tests/`, `docs/`) and RPG-Kit's in-workspace configuration files (`.rpgkit/config.toml`, `.claude/`, `.github/`, `.vscode/`, depending on the selected agent). - `rpgkit init --here` inside an existing git repository adds RPG-Kit on top of the existing code without moving the repository. -- A single `.git` repository tracks user-owned code and any RPG-Kit files the user chooses to commit. **Runtime data, logs, and the inner-git snapshot repo all live outside the workspace** under `~/.rpgkit/workspaces//`, so generated artefacts don't pollute your repo or accidentally get committed. Only a small set of user-facing files (`.rpgkit/config.toml`, `.rpgkit/reports/*.html`) stay inside the workspace. +- A single `.git` repository tracks user-owned code and any RPG-Kit files the user chooses to commit. **Runtime data, logs, and the inner-git snapshot repo all live outside the workspace** under `~/.rpgkit/workspaces//`, so generated artefacts don't pollute your repo or accidentally get committed. Only a small set of user-facing files (`.rpgkit/config.toml`, `.rpgkit/reports/*.html`) stay inside the workspace. ## After `rpgkit init` @@ -51,10 +51,10 @@ my-project/ ### Out-of-workspace runtime store -Starting from the global-install layout, all runtime state lives under your home directory, keyed by a stable hash of the workspace's absolute path: +Starting from the global-install layout, all runtime state lives under your home directory, keyed by a path-derived **slug** (the workspace's absolute path, lowercased, with non-alphanumeric runs collapsed to `-`): ```text -~/.rpgkit/workspaces// +~/.rpgkit/workspaces// ├── .git/ # Inner-git snapshot repo (per-stage auto-commits) ├── .gitignore # Excludes logs/copilot/ only — other logs are tracked for debug ├── .meta.toml # Back-pointer to the workspace path + metadata @@ -64,13 +64,13 @@ Starting from the global-install layout, all runtime state lives under your home Reports (`rpg.html`, review HTML, …) stay **inside** the workspace at `/.rpgkit/reports/` because they are small, user-facing artefacts that benefit from sitting next to the code (and may be committed). -The hash is computed as `sha256(os.path.realpath(workspace_path))[:12]`, so moving or renaming the workspace yields a different home directory. Run `rpgkit version` from inside the workspace to see the resolved paths (the **Data**, **Logs**, and **Inner git** lines). +`` is normally the slug itself (e.g. `home-hys-projects-myrepo`); paths whose slug exceeds 200 characters are truncated and given a 6-char base36 SHA-256 suffix so the directory name fits comfortably under POSIX `NAME_MAX` (255). Same shape as Claude Code's `~/.claude/projects/`. Moving or renaming the workspace yields a different id, so each clone has independent state. Run `rpgkit version` from inside the workspace to see the resolved paths (the **Data**, **Logs**, and **Inner git** lines). For backward compatibility, workspaces created before 0.1.4 (which used a 12-hex-char SHA-256 hash directory) continue to resolve correctly. > Pipeline scripts (formerly materialised into `.rpgkit/scripts/`) now live inside the installed `rpgkit-cli` wheel under `rpgkit_cli/core_pack/scripts/` and are invoked via the global [`rpgkit script `](cli-reference.md) command. They are no longer copied into each workspace, so `rpgkit init` produces a much smaller footprint and a single source of truth per CLI install. The agent configuration directory varies by the selected AI assistant and release package. For the verified CLI path, `--ai claude` installs `.claude/commands/`, while `--ai copilot` installs `.github/agents/`, `.github/prompts/`, and `.vscode/mcp.json`. -Command definitions are installed into the AI-agent-specific folder. Normal users should not need to inspect `~/.rpgkit/workspaces//data/` directly—run `rpgkit version` from the workspace to see all relevant paths. +Command definitions are installed into the AI-agent-specific folder. Normal users should not need to inspect `~/.rpgkit/workspaces//data/` directly—run `rpgkit version` from the workspace to see all relevant paths. ### Quick reference: where does each file live? @@ -82,16 +82,16 @@ Command definitions are installed into the AI-agent-specific folder. Normal user | Agent command definitions | `/.claude/` or `/.github/` | | MCP / VS Code config | `/.vscode/` | | Git hooks (`post-commit`, `post-merge`) | `/.git/hooks/` | -| Generated data (`rpg.json`, `dep_graph.json`, …) | `~/.rpgkit/workspaces//data/` | -| Per-stage logs | `~/.rpgkit/workspaces//logs/` | -| Inner-git snapshot repo | `~/.rpgkit/workspaces//.git/` | +| Generated data (`rpg.json`, `dep_graph.json`, …) | `~/.rpgkit/workspaces//data/` | +| Per-stage logs | `~/.rpgkit/workspaces//logs/` | +| Inner-git snapshot repo | `~/.rpgkit/workspaces//.git/` | | Pipeline scripts (read-only) | inside the installed `rpgkit-cli` wheel | To see the resolved paths for the current workspace, run `rpgkit version` from anywhere inside it. ## Generated Data Files -As you run `/rpgkit.*` commands, `~/.rpgkit/workspaces//data/` is progressively populated (paths below are shown relative to that directory): +As you run `/rpgkit.*` commands, `~/.rpgkit/workspaces//data/` is progressively populated (paths below are shown relative to that directory): | Generated file | Command | Description | | -------------- | ------- | ----------- | @@ -144,13 +144,13 @@ Typical producers and updaters: ## Runtime Logs and Reports -Runtime logs are written under `~/.rpgkit/workspaces//logs/`, for example: +Runtime logs are written under `~/.rpgkit/workspaces//logs/`, for example: -- `~/.rpgkit/workspaces//logs/encode.log` -- `~/.rpgkit/workspaces//logs/update_rpg.log` -- `~/.rpgkit/workspaces//logs/feature_build.log` -- `~/.rpgkit/workspaces//logs/build_data_flow.log` +- `~/.rpgkit/workspaces//logs/encode.log` +- `~/.rpgkit/workspaces//logs/update_rpg.log` +- `~/.rpgkit/workspaces//logs/feature_build.log` +- `~/.rpgkit/workspaces//logs/build_data_flow.log` -Execution traces are written under `~/.rpgkit/workspaces//data/trajectory/`. Review or diagnostic artifacts may be written under `/.rpgkit/reports/` when a command generates them. +Execution traces are written under `~/.rpgkit/workspaces//data/trajectory/`. Review or diagnostic artifacts may be written under `/.rpgkit/reports/` when a command generates them. To discover the home-side paths (data / logs / inner-git) for the current workspace, run `rpgkit version` from anywhere inside it—the relevant lines are labelled **Workspace**, **Data**, **Logs**, and **Inner git**. diff --git a/RPG-Kit/scripts/common/paths.py b/RPG-Kit/scripts/common/paths.py index cf4d932..1ea9e6a 100644 --- a/RPG-Kit/scripts/common/paths.py +++ b/RPG-Kit/scripts/common/paths.py @@ -14,7 +14,7 @@ └── .git/ ← single git repo at the workspace root ~/.rpgkit/ ← user-global storage - └── workspaces// + └── workspaces// ├── .meta.toml ← channel, timestamps, version ├── .git/ ← Plan-03 inner snapshot repo ├── data/ ← rpg.json, dep_graph.json, … @@ -22,7 +22,7 @@ └── logs/ ← *.log, mcp_calls.jsonl, … Machine-local data (``data/``, ``logs/``, the inner snapshot ``.git/``) -lives under ``~/.rpgkit/workspaces//`` so it survives independently +lives under ``~/.rpgkit/workspaces//`` so it survives independently of the workspace, never gets accidentally committed, and stays scoped to one user. The workspace dir keeps only the lightweight, team-shared files that benefit from being version-controlled alongside the code. @@ -193,8 +193,8 @@ def cmd_for(script_relpath: str) -> str: # Layout: # # RPGKIT_DIR = /.rpgkit/ (minimal marker tree: config.toml + .source) -# DATA_DIR = ~/.rpgkit/workspaces//data/ -# LOGS_DIR = ~/.rpgkit/workspaces//logs/ +# DATA_DIR = ~/.rpgkit/workspaces//data/ +# LOGS_DIR = ~/.rpgkit/workspaces//logs/ # REPORTS_DIR = /.rpgkit/reports/ (kept in workspace by # design: small, user-facing, may be git-tracked) # @@ -271,7 +271,7 @@ def cmd_for(script_relpath: str) -> str: # something the developer opens in a browser and may want to share / # commit alongside the source. Keeping it in ``.rpgkit/reports/`` also # means double-clicking it from a file explorer "just works" without -# having to dig into ``~/.rpgkit/workspaces//``. +# having to dig into ``~/.rpgkit/workspaces//``. RPG_HTML_FILE = REPORTS_DIR / "rpg.html" @@ -320,7 +320,7 @@ def ensure_rpgkit_dir() -> Path: """Ensure ``DATA_DIR`` exists and return its path. In the home-storage layout, ``DATA_DIR`` lives under - ``~/.rpgkit/workspaces//data/``. We only create the leaf + ``~/.rpgkit/workspaces//data/``. We only create the leaf directory here; full home-layout bootstrap (including ``.meta.toml``) is the responsibility of ``rpgkit init`` / ``rpgkit update``. Calling this from a script that lands in a diff --git a/RPG-Kit/scripts/common/rpg_io.py b/RPG-Kit/scripts/common/rpg_io.py index 7bf4213..98899e1 100644 --- a/RPG-Kit/scripts/common/rpg_io.py +++ b/RPG-Kit/scripts/common/rpg_io.py @@ -12,7 +12,7 @@ 2. **Silent corruption with no recovery path** — once truncated, the only "fix" was to re-encode from scratch. But the inner-git snapshot repo already holds the previous good state at - ``~/.rpgkit/workspaces//.git/``; we just weren't using it. + ``~/.rpgkit/workspaces//.git/``; we just weren't using it. This module fixes both with two complementary primitives: @@ -154,14 +154,14 @@ def safe_load_rpg(path: Path | str) -> Any: def _git_relpath_for(path: Path) -> Optional[str]: """Return the path relative to the home-workspace dir for git lookup. - ``rpg.json`` lives at ``~/.rpgkit/workspaces//data/rpg.json``; - the inner git repo is rooted at ``~/.rpgkit/workspaces//``, + ``rpg.json`` lives at ``~/.rpgkit/workspaces//data/rpg.json``; + the inner git repo is rooted at ``~/.rpgkit/workspaces//``, so the path we ``git checkout`` is ``data/rpg.json``. Falls back to ``None`` when ``path`` doesn't look like it lives under such a home dir (e.g. test fixtures passing absolute paths into ``/tmp``). """ parts = path.resolve().parts - # Look for ".rpgkit/workspaces//..." in the path's components. + # Look for ".rpgkit/workspaces//..." in the path's components. try: idx = parts.index(".rpgkit") if ( diff --git a/RPG-Kit/scripts/feature_spec_to_json.py b/RPG-Kit/scripts/feature_spec_to_json.py index 733bb35..a9ae48f 100644 --- a/RPG-Kit/scripts/feature_spec_to_json.py +++ b/RPG-Kit/scripts/feature_spec_to_json.py @@ -28,7 +28,7 @@ # Use the canonical paths from common.paths so the output location # matches what downstream stages (feature_build, feature_build_validation, # ...) expect. That resolves to -# ``~/.rpgkit/workspaces//data/feature_spec.json`` rather than the +# ``~/.rpgkit/workspaces//data/feature_spec.json`` rather than the # workspace-local ``.rpgkit/data/feature_spec.json`` this script used # to compute on its own — a mismatch that previously broke the # feature_spec → feature_build handoff. diff --git a/RPG-Kit/scripts/mcp_server.py b/RPG-Kit/scripts/mcp_server.py index 255f1ed..a0e8e88 100644 --- a/RPG-Kit/scripts/mcp_server.py +++ b/RPG-Kit/scripts/mcp_server.py @@ -79,7 +79,7 @@ def _resolve_rpg_path() -> str: The default (``RPG_FILE``) is provided by :mod:`common.paths`, which resolves to - ``~/.rpgkit/workspaces//data/rpg.json`` for the current + ``~/.rpgkit/workspaces//data/rpg.json`` for the current workspace (discovered by walking up from cwd looking for ``.rpgkit/config.toml``). Callers running ``rpgkit-mcp`` from any subdirectory of a workspace therefore get the right RPG file diff --git a/RPG-Kit/scripts/rpg_edit/save_plan.py b/RPG-Kit/scripts/rpg_edit/save_plan.py index 4c0e736..956870c 100644 --- a/RPG-Kit/scripts/rpg_edit/save_plan.py +++ b/RPG-Kit/scripts/rpg_edit/save_plan.py @@ -2,7 +2,7 @@ """Save an EditPlan JSON document to ``RPG_EDIT_PLAN_FILE``. Reads JSON from stdin, validates that it parses, and writes it to -``~/.rpgkit/workspaces//data/rpg_edit_plan.json``. Slash-command +``~/.rpgkit/workspaces//data/rpg_edit_plan.json``. Slash-command templates use this so they never need to know the physical (home-dir) location of the workspace. diff --git a/RPG-Kit/scripts/rpg_encoder/run_encode.py b/RPG-Kit/scripts/rpg_encoder/run_encode.py index f552f52..3b8cc84 100644 --- a/RPG-Kit/scripts/rpg_encoder/run_encode.py +++ b/RPG-Kit/scripts/rpg_encoder/run_encode.py @@ -166,7 +166,7 @@ def run_encode( html_content = generate_html(viz_data) # rpg.html is a user-facing artefact: keep it in the # workspace's .rpgkit/reports/ rather than next to the - # machine-side rpg.json under ~/.rpgkit/workspaces//. + # machine-side rpg.json under ~/.rpgkit/workspaces//. RPG_HTML_FILE.parent.mkdir(parents=True, exist_ok=True) viz_output = str(RPG_HTML_FILE) RPG_HTML_FILE.write_text(html_content, encoding="utf-8") diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 240e0be..1c3385e 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -320,7 +320,7 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) # # _SOURCE_BUNDLE / _SOURCE_LEGACY — provisioning channel; persisted as # ``channel`` in ``~/.rpgkit/workspaces/ -# /.meta.toml`` so subsequent +# /.meta.toml`` so subsequent # ``rpgkit update`` calls honour the # user's original choice. Mirrors the # constants in :mod:`rpgkit_cli._storage`. @@ -366,7 +366,7 @@ def _current_cli_version() -> str: def _read_source_marker(project_path: Path) -> str | None: """Return the recorded provisioning channel for ``project_path``. - Reads ``channel`` from ``~/.rpgkit/workspaces//.meta.toml``. + Reads ``channel`` from ``~/.rpgkit/workspaces//.meta.toml``. Returns ``None`` when no meta file exists (fresh workspace) or the channel field is missing. """ @@ -383,7 +383,7 @@ def _write_source_marker(project_path: Path, source: str) -> None: """Persist the provisioning channel in the home-side ``.meta.toml``. Replaces the legacy ``workspace/.rpgkit/.source`` text file with a - structured TOML record under ``~/.rpgkit/workspaces//`` that + structured TOML record under ``~/.rpgkit/workspaces//`` that also carries timestamps and the version of rpgkit-cli that last touched the workspace. See :mod:`rpgkit_cli._storage` for the layout rationale. @@ -1959,7 +1959,7 @@ def _run_initial_encode(project_path: Path) -> bool: of lines of ``RPGParser - INFO - ...``), so instead we: * Capture stderr in a reader thread and write it verbatim to - ``~/.rpgkit/workspaces//logs/encode.log`` — power users + ``~/.rpgkit/workspaces//logs/encode.log`` — power users can ``tail -f`` it for the full firehose. * Parse a handful of phase markers off each line to drive a :class:`rich.progress.Progress` bar with a spinner + current @@ -1991,7 +1991,7 @@ def _run_initial_encode(project_path: Path) -> bool: return False # Keep all generated artefacts (logs/data/inner-git) in the - # per-workspace home dir under ~/.rpgkit/workspaces//. The + # per-workspace home dir under ~/.rpgkit/workspaces//. The # workspace tree should stay clean — no .rpgkit/logs/ written here. from . import _storage log_dir = _storage.workspace_logs_dir(project_path) @@ -2739,15 +2739,15 @@ def _install_git_post_commit_hook(project_path: Path) -> bool: * **Phase 1 (foreground)**: ``update_graphs.py sync`` advances ``meta.git`` to the new HEAD. Output is teed into - ``~/.rpgkit/workspaces//logs/hooks.log``. + ``~/.rpgkit/workspaces//logs/hooks.log``. * **Phase 2 (background)**: ``update_graphs.py update-rpg`` is detached via ``subprocess.Popen(start_new_session=True)``. A mkdir-based directory lock at - ``~/.rpgkit/workspaces//logs/.update_rpg.lock`` serialises + ``~/.rpgkit/workspaces//logs/.update_rpg.lock`` serialises overlapping commits; locks older than 60 minutes are treated as orphaned and removed. The worker's stdout/stderr land in - ``~/.rpgkit/workspaces//logs/update_rpg.log``. + ``~/.rpgkit/workspaces//logs/update_rpg.log``. Both phases are best-effort: every failure path is swallowed inside :func:`hook` so a hook misbehaviour never blocks ``git commit``. @@ -3603,7 +3603,7 @@ def ensure_rpgkit_runtime_dirs( """Pre-create RPG-Kit runtime directories under ``~/.rpgkit/``. The per-workspace data, logs, and inner-git snapshot repo live - under the user's home directory at ``~/.rpgkit/workspaces//`` + under the user's home directory at ``~/.rpgkit/workspaces//`` rather than inside the workspace. Reports stay in the workspace (``/.rpgkit/reports/``) because they're user-facing artefacts. @@ -3617,11 +3617,11 @@ def ensure_rpgkit_runtime_dirs( upfront rather than lazily. Created (idempotent): - - ``~/.rpgkit/workspaces//data/`` - - ``~/.rpgkit/workspaces//data/trajectory/`` - - ``~/.rpgkit/workspaces//logs/`` + - ``~/.rpgkit/workspaces//data/`` + - ``~/.rpgkit/workspaces//data/trajectory/`` + - ``~/.rpgkit/workspaces//logs/`` - ``/.rpgkit/reports/`` - - ``~/.rpgkit/workspaces//.meta.toml`` (refreshed) + - ``~/.rpgkit/workspaces//.meta.toml`` (refreshed) The inner ``.git/`` directory is NOT created here; that's the responsibility of :mod:`rpgkit_cli._inner_git`, which seeds an @@ -4190,7 +4190,7 @@ def init( console.print(security_notice) # Pre-create runtime directories so early pipeline prompts that redirect - # to ~/.rpgkit/workspaces//logs/.log don't fail with "No such file or directory". + # to ~/.rpgkit/workspaces//logs/.log don't fail with "No such file or directory". ensure_rpgkit_runtime_dirs(project_path) steps_lines = [] @@ -4237,8 +4237,8 @@ def init( step_num += 1 steps_lines.append( - f"{step_num}. You can inspect each step's output under [cyan]~/.rpgkit/workspaces//data/[/cyan], " - f"and review detailed execution trajectories under [cyan]~/.rpgkit/workspaces//data/trajectory/[/cyan]. " + f"{step_num}. You can inspect each step's output under [cyan]~/.rpgkit/workspaces//data/[/cyan], " + f"and review detailed execution trajectories under [cyan]~/.rpgkit/workspaces//data/trajectory/[/cyan]. " f"Run [cyan]rpgkit version[/cyan] from inside the workspace to see the resolved Data / Logs / Inner-git paths." ) @@ -4300,7 +4300,7 @@ def init( ): console.print( "[dim]Inner snapshot repo initialised at " - "[cyan]~/.rpgkit/workspaces//.git[/cyan] \u2014 " + "[cyan]~/.rpgkit/workspaces//.git[/cyan] \u2014 " "run [cyan]rpgkit version[/cyan] for the exact path " "and a ready-to-paste `git -C` invocation.[/dim]" ) @@ -4683,7 +4683,7 @@ def update( _write_workspace_config(project_path, selected_ai) # Pre-create runtime directories so stage prompts that redirect - # to ~/.rpgkit/workspaces//logs/.log don't fail when the folder is + # to ~/.rpgkit/workspaces//logs/.log don't fail when the folder is # missing (e.g. user removed it, or workspace was created by an # older rpgkit init that didn't pre-create logs/). ensure_rpgkit_runtime_dirs(project_path, tracker=tracker) @@ -4794,7 +4794,7 @@ def update( ): console.print( "[dim]Initialised inner snapshot repo at " - "[cyan]~/.rpgkit/workspaces//.git[/cyan] for this workspace.[/dim]" + "[cyan]~/.rpgkit/workspaces//.git[/cyan] for this workspace.[/dim]" ) @@ -5121,7 +5121,7 @@ def hook(name: str = typer.Argument(..., help="Hook name: post-commit | post-mer """Dispatch from ``.git/hooks/`` to the matching Python handler. Resolves the current workspace via the standard cwd-walk, attaches - a hook log under ``~/.rpgkit/workspaces//logs/hooks.log``, + a hook log under ``~/.rpgkit/workspaces//logs/hooks.log``, and runs the per-hook orchestration. Every failure path is swallowed (logged, never raised) so a misbehaving hook never blocks the user's git operation. @@ -5372,7 +5372,7 @@ def version(): # invoked from inside an rpgkit workspace. Without this the user # has no obvious way to find their generated artefacts / logs after # we moved them out of the repo tree into ``~/.rpgkit/workspaces/ - # /`` — they'd have to compute the sha256 themselves. + # /`` — they'd have to derive the workspace id themselves. try: from . import _inner_git ws = _inner_git.find_workspace_root() diff --git a/RPG-Kit/src/rpgkit_cli/_inner_git.py b/RPG-Kit/src/rpgkit_cli/_inner_git.py index 7193689..85b14b8 100644 --- a/RPG-Kit/src/rpgkit_cli/_inner_git.py +++ b/RPG-Kit/src/rpgkit_cli/_inner_git.py @@ -2,8 +2,8 @@ Every successful (or failed) ``rpgkit script `` invocation auto-commits the current state of the per-workspace home directory at -``~/.rpgkit/workspaces//`` into a dedicated git repo at -``~/.rpgkit/workspaces//.git/``. This lets ``git log`` and +``~/.rpgkit/workspaces//`` into a dedicated git repo at +``~/.rpgkit/workspaces//.git/``. This lets ``git log`` and ``git diff`` show how pipeline stages change between runs. What gets tracked: @@ -150,7 +150,7 @@ def categorise_script(relpath: str) -> str: def _inner_git_dir(workspace: Path) -> Path: """Return the home directory used as ``git -C `` for the snapshots. - The directory is ``~/.rpgkit/workspaces//``; the inner repo's + The directory is ``~/.rpgkit/workspaces//``; the inner repo's ``.git`` sits directly inside it. """ return _storage.home_workspace_dir(workspace) @@ -219,7 +219,7 @@ def _run_git(workspace: Path, *args: str, check: bool = False, timeout: int = 30 # --------------------------------------------------------------------------- def ensure_inner_git(workspace: Path, *, initial_msg: Optional[str] = None) -> bool: - """Create ``~/.rpgkit/workspaces//.git`` if missing. + """Create ``~/.rpgkit/workspaces//.git`` if missing. Returns ``True`` when a fresh repo was created, ``False`` when it already existed or when setup was skipped (git missing, home dir diff --git a/RPG-Kit/src/rpgkit_cli/_storage.py b/RPG-Kit/src/rpgkit_cli/_storage.py index afc22fe..59ad3e0 100644 --- a/RPG-Kit/src/rpgkit_cli/_storage.py +++ b/RPG-Kit/src/rpgkit_cli/_storage.py @@ -4,7 +4,7 @@ centralised one rooted at ``~/.rpgkit/``: ~/.rpgkit/ - workspaces// + workspaces// .meta.toml {workspace_path, channel, created_at, last_seen_at} .git/ inner git snapshot repo data/ rpg.json, dep_graph.json @@ -19,15 +19,30 @@ Workspace identity ------------------ -Each workspace is identified by the SHA-256 hash (first 12 hex chars) of -its **resolved absolute path**. Hash collisions are detected at read -time by comparing ``workspace_path`` recorded in ``.meta.toml``; a -mismatch produces a clear error rather than silently mixing two -workspaces' data. - -Why a hash and not a path-based directory tree? A flat hash gives every -workspace a fixed-length key that's safe to use as a directory name on -all filesystems, regardless of the original path's depth or characters. +Each workspace is identified by a **path-derived slug** (the resolved +absolute path with non-alphanumeric runs collapsed to ``-``). Short +paths produce a readable id like ``home-hys-projects-myrepo``; paths +whose slug would exceed 200 characters are truncated and given a +6-character base36 SHA-256 suffix so the id fits comfortably under +POSIX ``NAME_MAX`` (255). Same shape as Claude Code's +``~/.claude/projects/`` directory naming, with two improvements: no +leading dash, and a deterministic overflow strategy instead of relying +on the OS to error out. + +Slug collisions are theoretically possible (e.g. ``/foo/bar`` and +``/foo-bar`` both slug to ``foo-bar``); they are detected at read time +by comparing ``workspace_path`` recorded in ``.meta.toml``; a +collision aborts cleanly rather than silently overwriting state. + +Why a readable slug and not a flat hash? Users routinely browse +``~/.rpgkit/workspaces/`` to find logs, delete stale state, or sanity- +check which workspace a process is talking to; a slug makes that ten +times easier than an opaque hex hash. We accept a small (negligible +in practice) collision risk in exchange. + +For backward compatibility, when no slug-named directory exists, +:func:`home_workspace_dir` falls back to the pre-0.1.4 12-char hex +hash layout if one is present on disk. Resolution ---------- @@ -41,8 +56,8 @@ Public surface -------------- -* :func:`workspace_id` - the 12-char hash for a workspace path. -* :func:`home_workspace_dir` - ``~/.rpgkit/workspaces//``. +* :func:`workspace_id` - the slug (or slug+hash suffix) for a workspace path. +* :func:`home_workspace_dir` - ``~/.rpgkit/workspaces//``. * :func:`workspace_data_dir`, :func:`workspace_logs_dir`, :func:`workspace_inner_git_dir`, :func:`workspace_reports_dir` - convenience wrappers for the four canonical subdirectories. @@ -59,7 +74,7 @@ * No symlinks are created in the workspace (avoids Windows headaches and accidental backup-tool double-counting). * All path inputs are run through :py:meth:`Path.resolve` so symlinked - workspace roots map to a single canonical hash. + workspace roots map to a single canonical id. * All filesystem mutations are best-effort idempotent so re-running ``rpgkit init`` or ``rpgkit update`` is safe. """ @@ -67,6 +82,7 @@ import hashlib import os +import re from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, Optional @@ -106,7 +122,7 @@ # --------------------------------------------------------------------------- -# Hash + path resolution +# Workspace ID + path resolution # --------------------------------------------------------------------------- def _resolve(path: Path) -> Path: @@ -120,18 +136,93 @@ def _resolve(path: Path) -> Path: return Path(path).resolve() +# Tunables for :func:`workspace_id`. Picked so the worst-case id +# (truncated slug + ``-`` + 6 base36 chars) stays under ``NAME_MAX`` +# (255 on Linux/macOS, 255 UTF-16 code units on Windows NTFS) with +# plenty of headroom for downstream nested paths. +_SLUG_MAX_LEN = 200 +_HASH_SUFFIX_LEN = 6 +_LEGACY_HASH_LEN = 12 + +#: Pattern used by :func:`_slugify` to collapse non-alphanumeric runs. +_NON_ALNUM_RE = re.compile(r"[^a-zA-Z0-9]+") + +#: Pattern matching the pre-0.1.4 legacy id format (12 lowercase hex chars). +_LEGACY_HASH_RE = re.compile(r"^[0-9a-f]{12}$") + +#: Base36 alphabet used for the overflow hash suffix. Matches Claude +#: Code's convention for ``~/.claude/projects/`` and packs ~5 bits per +#: char (36**6 ≈ 2.2e9 — collision probability negligible at our scale). +_BASE36_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" + + +def _slugify_path(workspace_path: Path) -> str: + """Return the slug (no hash) for a resolved workspace path. + + Algorithm: replace every run of non-alphanumeric chars with ``-``, + strip leading/trailing ``-``, lowercase. Result is always safe to + use as a directory name on every filesystem we target. + + Examples: + ``/home/hys/projects/rpgkit`` -> ``home-hys-projects-rpgkit`` + ``C:\\Users\\foo\\bar`` -> ``c-users-foo-bar`` + ``/`` -> ``root`` + """ + canonical = str(_resolve(workspace_path)) + slug = _NON_ALNUM_RE.sub("-", canonical).strip("-").lower() + return slug or "root" + + +def _base36_hash(workspace_path: Path) -> str: + """Return the 6-char base36 SHA-256 prefix used for overflow ids.""" + canonical = str(_resolve(workspace_path)) + digest = hashlib.sha256(canonical.encode("utf-8")).digest() + n = int.from_bytes(digest[: _HASH_SUFFIX_LEN], "big") + out = [] + for _ in range(_HASH_SUFFIX_LEN): + out.append(_BASE36_ALPHABET[n % 36]) + n //= 36 + return "".join(reversed(out)) + + +def _legacy_workspace_id(workspace_path: Path) -> str: + """Compute the pre-0.1.4 workspace id (SHA-256 first 12 hex chars). + + Only used by :func:`home_workspace_dir` as a backward-compat probe + so users who upgraded across the slug-naming change keep finding + their existing on-disk state. + """ + canonical = str(_resolve(workspace_path)) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:_LEGACY_HASH_LEN] + + def workspace_id(workspace_path: Path) -> str: - """Compute the 12-character workspace identifier for ``workspace_path``. + """Compute the workspace identifier for ``workspace_path``. + + Two formats: + + * **Short (preferred)** — when the path slug is ≤ 200 chars, use + the slug verbatim, e.g. ``home-hys-projects-rpgkit``. Readable + at a glance; lets users browse ``~/.rpgkit/workspaces/`` and + identify their projects without cross-referencing a hash table. + * **Truncated (overflow)** — when the slug exceeds the budget, + keep the first ~193 chars and append ``-`` where + ``hash6`` is a 6-character base36 SHA-256 prefix. Guarantees + uniqueness across arbitrarily long paths while staying under + ``NAME_MAX`` (255). The identifier is deterministic on a given machine: the same - resolved absolute path always yields the same hash. Different + resolved absolute path always yields the same id. Different paths (including different clones of the same git repo) yield - different hashes — this is intentional so each clone has independent + different ids — this is intentional so each clone has independent state. """ - canonical = str(_resolve(workspace_path)) - digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() - return digest[:12] + slug = _slugify_path(workspace_path) + if len(slug) <= _SLUG_MAX_LEN: + return slug + # Reserve room for ``-`` + hash suffix. + head = slug[: _SLUG_MAX_LEN - _HASH_SUFFIX_LEN - 1].rstrip("-") + return f"{head}-{_base36_hash(workspace_path)}" # --------------------------------------------------------------------------- @@ -150,10 +241,24 @@ def home_root() -> Path: def home_workspace_dir(workspace_path: Path) -> Path: """Return the home directory assigned to ``workspace_path``. - This is ``~/.rpgkit/workspaces//`` for whichever hash the path - resolves to. The directory may or may not exist on disk. + Normally this is ``~/.rpgkit/workspaces//`` using the + slug-based id from :func:`workspace_id`. + + Backward compatibility: if a directory under the **legacy** 12-char + hex id already exists on disk (created by rpgkit < 0.1.4) and no + slug-named directory exists for the same path, the legacy directory + is returned so the user keeps reaching their existing state after + upgrading. New workspaces always use the slug-based layout. + + The directory may or may not exist on disk. """ - return home_root() / workspace_id(workspace_path) + new_dir = home_root() / workspace_id(workspace_path) + if new_dir.exists(): + return new_dir + legacy_dir = home_root() / _legacy_workspace_id(workspace_path) + if legacy_dir.exists(): + return legacy_dir + return new_dir def workspace_data_dir(workspace_path: Path) -> Path: @@ -391,7 +496,7 @@ def ensure_workspace_storage( Creates:: - ~/.rpgkit/workspaces// + ~/.rpgkit/workspaces// data/ logs/ @@ -406,7 +511,7 @@ def ensure_workspace_storage( seed an initial commit message. Returns: - The home workspace directory (``~/.rpgkit/workspaces//``). + The home workspace directory (``~/.rpgkit/workspaces//``). """ resolved = _resolve(workspace_path) home_dir = home_workspace_dir(resolved) diff --git a/RPG-Kit/tests/test_rpg_io.py b/RPG-Kit/tests/test_rpg_io.py index f081054..1d9784d 100644 --- a/RPG-Kit/tests/test_rpg_io.py +++ b/RPG-Kit/tests/test_rpg_io.py @@ -29,7 +29,7 @@ def _has_git() -> bool: def _make_home_layout(tmp_path: Path, hash_id: str = "abc123def456") -> Path: - """Create the ``~/.rpgkit/workspaces//`` layout for tests. + """Create the ``~/.rpgkit/workspaces//`` layout for tests. Returns the home_dir (the dir that gets ``git init``). Caller is responsible for git-initialising and snapshotting it. diff --git a/RPG-Kit/tests/test_storage.py b/RPG-Kit/tests/test_storage.py index fe20061..9d04eb6 100644 --- a/RPG-Kit/tests/test_storage.py +++ b/RPG-Kit/tests/test_storage.py @@ -66,10 +66,51 @@ def test_different_paths_differ(self, tmp_path: Path) -> None: assert _storage.workspace_id(a) != _storage.workspace_id(b) def test_hash_length_is_12(self, workspace: Path) -> None: - wid = _storage.workspace_id(workspace) + """Pre-0.1.4 legacy id is still computable for backward compat.""" + wid = _storage._legacy_workspace_id(workspace) assert len(wid) == 12 assert all(c in "0123456789abcdef" for c in wid) + def test_short_path_returns_plain_slug(self, tmp_path: Path) -> None: + """Common case: slug below the budget, no hash suffix.""" + ws = tmp_path / "myrepo" + ws.mkdir() + wid = _storage.workspace_id(ws) + # The slug should include the workspace dir name and contain only + # lowercase alphanumerics + ``-``. + assert "myrepo" in wid + assert all(c.isalnum() or c == "-" for c in wid) + assert not wid.startswith("-") + assert not wid.endswith("-") + # No overflow hash suffix for a short path. + assert "-" + _storage._base36_hash(ws) not in wid + + def test_long_path_truncates_and_appends_hash( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Overflow case: id is truncated and ends with a base36 hash.""" + # Synthesise a workspace whose slug far exceeds the budget by + # monkey-patching ``_resolve`` (creating a 300-deep dir tree is + # slow and noisy on disk). + fake_path = Path("/" + "/".join("seg%02d" % i for i in range(60))) + monkeypatch.setattr(_storage, "_resolve", lambda p: fake_path) + + wid = _storage.workspace_id(tmp_path) + assert len(wid) <= _storage._SLUG_MAX_LEN, ( + "workspace_id must stay under NAME_MAX budget" + ) + # Suffix shape: ``-<6 base36 chars>``. + assert wid[-7] == "-" + suffix = wid[-_storage._HASH_SUFFIX_LEN :] + assert all(c in _storage._BASE36_ALPHABET for c in suffix) + # Deterministic across calls. + assert _storage.workspace_id(tmp_path) == wid + + def test_root_path_returns_root(self, monkeypatch: pytest.MonkeyPatch) -> None: + """``/`` slugs to ``root`` (avoids empty directory name).""" + monkeypatch.setattr(_storage, "_resolve", lambda p: Path("/")) + assert _storage.workspace_id(Path("/")) == "root" + # --------------------------------------------------------------------------- # Path helpers @@ -98,6 +139,40 @@ def test_reports_dir_under_workspace( reports = _storage.workspace_reports_dir(workspace) assert reports == workspace.resolve() / ".rpgkit" / "reports" + def test_legacy_hash_dir_fallback( + self, fake_home: Path, workspace: Path + ) -> None: + """Pre-0.1.4 directories using the 12-hex-char id are honoured. + + When a user upgrades and their on-disk state lives under the old + ```` directory, ``home_workspace_dir`` must keep + returning that directory so the user doesn't silently lose state. + """ + # Plant a legacy directory but **no** slug-named one. + legacy_dir = ( + fake_home / ".rpgkit" / "workspaces" / _storage._legacy_workspace_id(workspace) + ) + legacy_dir.mkdir(parents=True) + assert _storage.home_workspace_dir(workspace) == legacy_dir + + def test_slug_dir_wins_over_legacy( + self, fake_home: Path, workspace: Path + ) -> None: + """When both legacy and slug dirs exist, the slug dir wins. + + Lets users migrate by simply creating the slug dir (or letting + the next ``rpgkit init`` do it) without manual cleanup. + """ + legacy_dir = ( + fake_home / ".rpgkit" / "workspaces" / _storage._legacy_workspace_id(workspace) + ) + legacy_dir.mkdir(parents=True) + slug_dir = ( + fake_home / ".rpgkit" / "workspaces" / _storage.workspace_id(workspace) + ) + slug_dir.mkdir(parents=True) + assert _storage.home_workspace_dir(workspace) == slug_dir + # --------------------------------------------------------------------------- # find_workspace_root_from @@ -141,7 +216,7 @@ def test_skips_stale_marker_with_mismatched_meta( renamed) and the walker keeps climbing rather than misrouting.""" self._mark(workspace) # Forge meta recording a *different* absolute path under - # ``~/.rpgkit/workspaces//.meta.toml``. + # ``~/.rpgkit/workspaces//.meta.toml``. meta_path = _storage.workspace_meta_path(workspace) meta_path.parent.mkdir(parents=True, exist_ok=True) meta_path.write_text( From 5bcc0a755c787af00fbe0543bbb97d8c451c6817 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 21:05:30 +0800 Subject: [PATCH 30/31] refactor(rpgkit-cli): drop --pull, rename --no-pull to --no-upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --pull flag was redundant with the default-on self-upgrade behaviour, and its name (evoking 'git pull') conflated CLI self-upgrade with workspace template sync. Drop it entirely. Rename --no-pull to --no-upgrade for the opt-out path so the flag name describes what it actually does (skip the rpgkit-cli upgrade). Editable / local / unknown install sources continue to be skipped silently by default; --no-upgrade is the supported escape hatch for offline or version-pinned environments. This is a breaking change carried in the same v0.1.4 wave as the global-install layout — no deprecation alias is provided. --- RPG-Kit/docs/cli-reference.md | 7 +-- RPG-Kit/src/rpgkit_cli/__init__.py | 87 ++++++++++-------------------- 2 files changed, 31 insertions(+), 63 deletions(-) diff --git a/RPG-Kit/docs/cli-reference.md b/RPG-Kit/docs/cli-reference.md index 397db67..b9e7b43 100644 --- a/RPG-Kit/docs/cli-reference.md +++ b/RPG-Kit/docs/cli-reference.md @@ -72,8 +72,7 @@ rpgkit update --github-token $GITHUB_TOKEN | `--github-token ` | GitHub token for private repos or higher rate limits | | `--pre` | Download the latest pre-release template | | `--legacy-download` | Bypass the packaged assets and pull from the latest GitHub release zip (implied by `--pre`) | -| `--pull` | Force a self-upgrade of the CLI (auto-detects uv / pipx / pip) before syncing the workspace. Conflicts with `--no-pull`. | -| `--no-pull` | Skip the self-upgrade and only sync workspace files. Conflicts with `--pull`. | +| `--no-upgrade` | Skip the default-on CLI self-upgrade and only sync workspace files. | | `--no-mcp` | Skip MCP server configuration | | `--skip-tls` | Skip SSL/TLS verification | | `--debug` | Show verbose diagnostic output | @@ -82,9 +81,7 @@ rpgkit update --github-token $GITHUB_TOKEN Since the global-install layout, `rpgkit update` performs a **best-effort silent self-upgrade by default** when the install source is safe to refresh (git+URL or PyPI). After upgrading the CLI it re-executes itself once to continue the workspace sync with the new code. Editable installs, local-file installs, and unknown sources are skipped silently. -- Pass `--pull` to force an upgrade attempt regardless of the detected source. -- Pass `--no-pull` to skip the upgrade entirely (useful for offline or pinned environments). -- `--pull` and `--no-pull` are mutually exclusive; passing both exits with status 2. +- Pass `--no-upgrade` to skip the upgrade entirely (useful for offline or pinned environments). - A loop guard environment variable (`RPGKIT_UPGRADE_DONE`) is set across the re-exec to guarantee at most one upgrade attempt per invocation. ### Provisioning sources diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index 1c3385e..d82e74f 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -427,8 +427,8 @@ def _detect_install_method() -> str: """Best-effort detection of how ``rpgkit-cli`` was installed. Returns one of ``"uv"``, ``"pipx"``, ``"pip-user"``, ``"pip-system"``, - ``"editable"``, ``"unknown"``. Used by ``rpgkit update --pull`` to - pick the right upgrade command. + ``"editable"``, ``"unknown"``. Used by ``rpgkit update`` to pick the + right self-upgrade command. """ try: # Do not call ``.resolve()`` here. The python @@ -447,8 +447,8 @@ def _detect_install_method() -> str: # IMPORTANT: editable detection must run FIRST. An editable install # placed inside a uv-managed venv would otherwise be reported as - # "uv" and ``rpgkit update --pull`` would try to upgrade from the - # registry instead of asking the user to `git pull` their checkout. + # "uv" and ``rpgkit update`` would try to upgrade from the + # registry instead of leaving the local checkout alone. try: import importlib.metadata as _im @@ -4369,20 +4369,11 @@ def update( "the CLI you have installed. Implied by --pre." ), ), - pull: bool = typer.Option( + no_upgrade: bool = typer.Option( False, - "--pull", + "--no-upgrade", help=( - "Force the pre-update CLI upgrade even when auto-detection " - "would have skipped it (e.g. an editable / local-path " - "install). Mutually exclusive with --no-pull." - ), - ), - no_pull: bool = typer.Option( - False, - "--no-pull", - help=( - "Skip the default-on CLI upgrade step. Use when offline, " + "Skip the default-on CLI self-upgrade step. Use when offline, " "on a version-pinned CI runner, or when you've just " "installed the CLI manually." ), @@ -4493,11 +4484,8 @@ def update( # * the install source is remote (git URL or PyPI), meaning the # user isn't actively developing the CLI from a local checkout. # - # ``--no-pull`` skips this step (offline / pinned CI / freshly - # re-installed manually). ``--pull`` forces it even for sources - # we'd otherwise skip (local / editable / unknown) — useful when a - # power user really does want the registry build to overwrite - # their local install. + # ``--no-upgrade`` skips this step (offline / pinned CI / freshly + # re-installed manually). # # After a successful upgrade we ``os.execvp`` the (now-upgraded) # rpgkit binary so the rest of update runs against the freshly @@ -4509,12 +4497,6 @@ def update( # upgrade attempt unconditionally so an idempotent ``uv tool # upgrade`` (which returns 0 even when there's nothing to upgrade) # doesn't loop forever. - if pull and no_pull: - console.print( - "[red]error:[/red] --pull and --no-pull are mutually exclusive" - ) - raise typer.Exit(2) - _UPGRADE_DONE_ENV = "RPGKIT_UPGRADE_DONE" already_upgraded = bool(os.environ.get(_UPGRADE_DONE_ENV)) @@ -4525,33 +4507,23 @@ def update( if already_upgraded: do_upgrade = False skip_reason = "" # silent — internal marker, not user-visible - elif no_pull: + elif no_upgrade: + do_upgrade = False + skip_reason = "--no-upgrade" + elif cmd is None: + do_upgrade = False + skip_reason = ( + f"install method '{method}' has no auto-upgrade path " + f"(upgrade manually)" + ) + elif source not in _AUTO_UPGRADE_SOURCES: do_upgrade = False - skip_reason = "--no-pull" - elif pull: - do_upgrade = cmd is not None skip_reason = ( - f"--pull but no upgrade command for install method '{method}'" - if cmd is None else "" + f"local/dev install (source={source!r}); skipping auto-upgrade." ) else: - # Default-on policy: upgrade only when both the install method - # and the install source say it's safe. - if cmd is None: - do_upgrade = False - skip_reason = ( - f"install method '{method}' has no auto-upgrade path " - f"(use --pull to force, or upgrade manually)" - ) - elif source not in _AUTO_UPGRADE_SOURCES: - do_upgrade = False - skip_reason = ( - f"local/dev install (source={source!r}); skipping " - f"auto-upgrade. Use --pull to force." - ) - else: - do_upgrade = True - skip_reason = "" + do_upgrade = True + skip_reason = "" if do_upgrade: console.print( @@ -4580,11 +4552,10 @@ def update( if rc == 0: # Re-exec the upgraded binary so the rest of update runs - # against the freshly-installed code + assets. Strip - # ``--pull`` (no longer needed) but keep every other flag. - # Set the loop-guard env var so the re-exec'd process - # doesn't immediately try to upgrade again. - new_argv = [a for a in sys.argv if a != "--pull"] + # against the freshly-installed code + assets. Set the + # loop-guard env var so the re-exec'd process doesn't + # immediately try to upgrade again. + new_argv = list(sys.argv) rpgkit_bin = shutil.which("rpgkit") or new_argv[0] console.print( "[cyan]CLI upgrade complete; re-exec'ing to apply " @@ -4610,10 +4581,10 @@ def update( f"continuing with currently installed version.[/yellow]" ) elif skip_reason: - # Surface the reason only when the user explicitly asked via - # --pull but we couldn't help; the default-on skip path stays + # Surface the reason only when the user explicitly opted out; + # the default-on skip paths (editable, no upgrade cmd) stay # quiet for the 99% case where nothing to do. - if pull or skip_reason == "--no-pull": + if skip_reason == "--no-upgrade": console.print(f"[dim]update: skipping CLI upgrade ({skip_reason}).[/dim]") # Build step tracker From 66fef318eaa224579f1d8bdbc1c4a188850105b0 Mon Sep 17 00:00:00 2001 From: Yasen Hu <74404492+HuYaSen@users.noreply.github.com> Date: Fri, 22 May 2026 21:17:36 +0800 Subject: [PATCH 31/31] refactor(rpgkit-cli): drop CLI flags for release-zip provisioning channel As of v0.1.4, rpgkit init/update provision exclusively from the bundled templates shipped inside the installed wheel (rpgkit_cli/core_pack/). Users pick up newer prompts by upgrading the CLI itself, which 'rpgkit update' does automatically by default (opt out with --no-upgrade). Removed CLI flags (no longer reachable, no replacement needed): - --legacy-download / --pre (release-zip channel is gone) - --github-token (no GitHub API calls at provisioning time) - --skip-tls (no HTTPS requests to skip TLS for) Reject --script ps with a 'not yet supported, coming soon' message; default script type is now sh on every platform (PS templates aren't wired up yet). Soft-deprecation: the underlying _download_and_extract_release_zip path and GitHub API helpers remain in the source with DEPRECATED header comments so the change is reversible. Slated for actual removal (along with the httpx dependency) in v0.2.0. Tests: 868 pass, 29 pre-existing fail (no new regressions). --- RPG-Kit/docs/cli-reference.md | 57 +++----- RPG-Kit/docs/configuration.md | 36 ++--- RPG-Kit/src/rpgkit_cli/__init__.py | 210 +++++++++-------------------- 3 files changed, 91 insertions(+), 212 deletions(-) diff --git a/RPG-Kit/docs/cli-reference.md b/RPG-Kit/docs/cli-reference.md index b9e7b43..0385716 100644 --- a/RPG-Kit/docs/cli-reference.md +++ b/RPG-Kit/docs/cli-reference.md @@ -17,16 +17,12 @@ rpgkit init . [options] | Option | Description | | ------ | ----------- | | `--ai ` | AI assistant: `copilot` or `claude` | -| `--script ` | Script type: `sh` (POSIX) or `ps` (PowerShell) | +| `--script ` | Script type: `sh` (POSIX). `ps` (PowerShell) is not yet supported and will be added in a future release. | | `--here` | Initialize in current directory | | `--force` | Skip confirmation for non-empty current directory | | `--no-git` | Skip git initialization | | `--no-mcp` | Skip MCP server configuration | | `--ignore-agent-tools` | Skip checks for AI agent CLI tools | -| `--github-token ` | GitHub token for private repos or higher rate limits | -| `--pre` | Download the latest pre-release template | -| `--legacy-download` | Bypass the packaged assets and pull templates from the latest GitHub release zip (implied by `--pre`) | -| `--skip-tls` | Skip SSL/TLS verification | | `--encode/--no-encode` | Run or skip initial RPG encoding at the end of init | | `--debug` | Show verbose diagnostic output | @@ -48,7 +44,6 @@ rpgkit init . --force rpgkit init . --encode rpgkit init . --force --encode rpgkit init --here --ai copilot -rpgkit init --here --github-token $GITHUB_TOKEN ``` ## `rpgkit update` @@ -58,9 +53,8 @@ Update RPG-Kit template files, scripts, command definitions, MCP configuration, ```bash rpgkit update rpgkit update --ai claude -rpgkit update --pre rpgkit update --no-mcp -rpgkit update --github-token $GITHUB_TOKEN +rpgkit update --no-upgrade ``` ### Options @@ -68,13 +62,9 @@ rpgkit update --github-token $GITHUB_TOKEN | Option | Description | | ------ | ----------- | | `--ai ` | AI assistant, auto-detected if not specified | -| `--script ` | Script type: `sh` (POSIX) or `ps` (PowerShell) | -| `--github-token ` | GitHub token for private repos or higher rate limits | -| `--pre` | Download the latest pre-release template | -| `--legacy-download` | Bypass the packaged assets and pull from the latest GitHub release zip (implied by `--pre`) | +| `--script ` | Script type: `sh` (POSIX). `ps` (PowerShell) is not yet supported and will be added in a future release. | | `--no-upgrade` | Skip the default-on CLI self-upgrade and only sync workspace files. | | `--no-mcp` | Skip MCP server configuration | -| `--skip-tls` | Skip SSL/TLS verification | | `--debug` | Show verbose diagnostic output | ### Auto-upgrade behaviour @@ -86,22 +76,29 @@ Since the global-install layout, `rpgkit update` performs a **best-effort silent ### Provisioning sources -Since `0.1.3`, `rpgkit init` and `rpgkit update` provision from two channels: +As of `0.1.4`, `rpgkit init` and `rpgkit update` provision exclusively +from the **packaged assets bundle** shipped inside the installed +`rpgkit-cli` wheel (under `rpgkit_cli/core_pack/`). No network access +is required at provisioning time. -| Channel | When used | Network needed | -| ------- | --------- | -------------- | -| Packaged assets (bundle) | Default. Pulled from `rpgkit_cli/core_pack/` inside the installed wheel | No | -| GitHub release zip (legacy) | `--legacy-download`, `--pre`, or `--script ps`, or when the bundle is unavailable (e.g. editable installs) | Yes | +To pick up newer prompts and templates, upgrade the CLI itself +(e.g. `uv tool upgrade rpgkit-cli`). `rpgkit update` does this +automatically by default (see *Auto-upgrade behaviour* above); pass +`--no-upgrade` to opt out. -`rpgkit init` records the chosen channel (`bundle` or `legacy`) in `~/.rpgkit/workspaces//.meta.toml` so subsequent `rpgkit update` invocations default to the same channel. Override with the flag of your choice at any time. +## `rpgkit check` -Verify that required tools are installed. +Verify that the local environment has the tools RPG-Kit relies on. ```bash rpgkit check ``` -Run this after installation to confirm Python, Git, uv, and the selected AI assistant CLI are available. +Probes for Git, the supported AI assistant CLIs (GitHub Copilot, +Claude Code), and optional editors (VS Code / VS Code Insiders), and +prints a tree of which ones are available. Run this after +installation to confirm the environment is ready, or whenever a +pipeline step complains about a missing tool. ## `rpgkit version` @@ -152,21 +149,3 @@ A companion console script, `rpgkit-mcp`, is the MCP server entry point and is what `.mcp.json` / `.vscode/mcp.json` register as the `rpg-tools` command — no absolute paths in the config, no per-machine edits. - -## Network and Release Options - -```bash -rpgkit init my-project --github-token $GITHUB_TOKEN -rpgkit init my-project --pre -rpgkit init my-project --skip-tls -rpgkit init my-project --debug -``` - -| Option | Description | -| ------ | ----------- | -| `--github-token ` | Uses a GitHub token for API requests, useful for private repos or rate limits | -| `--pre` | Downloads the latest pre-release template instead of the latest stable release | -| `--skip-tls` | Skips SSL/TLS verification; use only for constrained environments | -| `--debug` | Prints verbose diagnostic output for network and extraction failures | - -`GH_TOKEN` and `GITHUB_TOKEN` are also recognized for GitHub API requests. diff --git a/RPG-Kit/docs/configuration.md b/RPG-Kit/docs/configuration.md index b796573..4230622 100644 --- a/RPG-Kit/docs/configuration.md +++ b/RPG-Kit/docs/configuration.md @@ -44,7 +44,7 @@ When a pipeline script (or a hook, or the MCP server) needs to invoke the AI CLI | P1 | `LLMClient(tool="...")` constructor argument | Programmatic override (rare) | | P2 | `RPGKIT_AI_CLI_CMD` environment variable | CI runs, one-off experiments | | P3 | `.rpgkit/config.toml` `[rpgkit].ai_cli_cmd` | Normal default (per workspace) | -| P4 | Release-zip baked-in literal | Workspaces provisioned with `--legacy-download` | +| P4 | Release-zip baked-in literal | Legacy workspaces provisioned before v0.1.4 | If all four resolve to empty, the next `LLMClient.generate()` call raises a `RuntimeError` instructing the user to run `rpgkit init` or set the env var. @@ -218,30 +218,12 @@ Run `rpgkit update` from the project root to refresh scripts, command definition ```bash rpgkit update rpgkit update --ai claude -rpgkit update --pre +rpgkit update --no-upgrade rpgkit update --no-mcp ``` `rpgkit update` auto-detects the existing assistant configuration when possible. -## Network and Release Options - -```bash -rpgkit init my-project --github-token $GITHUB_TOKEN -rpgkit init my-project --pre -rpgkit init my-project --skip-tls -rpgkit init my-project --debug -``` - -| Option | Description | -| ------ | ----------- | -| `--github-token ` | Uses a GitHub token for API requests, useful for private repos or rate limits | -| `--pre` | Downloads the latest pre-release template instead of the latest stable release | -| `--skip-tls` | Skips SSL/TLS verification; use only for constrained environments | -| `--debug` | Prints verbose diagnostic output for network and extraction failures | - -`GH_TOKEN` and `GITHUB_TOKEN` are also recognized for GitHub API requests. - ## Troubleshooting ### AI assistant CLI not found @@ -285,14 +267,14 @@ If the graph is corrupted or too stale, run `/rpgkit.encode` for a full rebuild. ### Template download hits rate limits or private repo access errors -Use a token: +As of v0.1.4 `rpgkit init` and `rpgkit update` no longer fetch templates +from GitHub releases — templates are bundled inside the installed +`rpgkit-cli` wheel, so this class of error should no longer occur during +provisioning. To pick up newer templates, upgrade the CLI itself: ```bash -rpgkit init my-project --github-token $GITHUB_TOKEN +uv tool upgrade rpgkit-cli ``` -or set an environment variable: - -```bash -export GH_TOKEN=your_token -``` +`rpgkit update` does this automatically by default; pass `--no-upgrade` +to opt out. diff --git a/RPG-Kit/src/rpgkit_cli/__init__.py b/RPG-Kit/src/rpgkit_cli/__init__.py index d82e74f..9e421ec 100644 --- a/RPG-Kit/src/rpgkit_cli/__init__.py +++ b/RPG-Kit/src/rpgkit_cli/__init__.py @@ -65,6 +65,23 @@ _RPGKIT_RELEASE_TAG_PREFIX = "rpgkit-v" +# --------------------------------------------------------------------------- +# DEPRECATED: GitHub release-zip provisioning helpers. +# +# As of v0.1.4 ``rpgkit init`` / ``rpgkit update`` are bundle-only and no +# longer fetch templates from GitHub releases at runtime — users upgrade +# the CLI itself to pick up newer prompts. The helpers below +# (``_parse_github_owner_repo``, ``_github_token``, +# ``_github_auth_headers``, ``_parse_rate_limit_headers``, +# ``_format_rate_limit_error``, ``_is_private_repo``, +# ``_get_asset_download_url``, ``_fetch_latest_rpgkit_release``, +# ``download_template_from_github``, ``_download_and_extract_release_zip``) +# are kept temporarily so the change is reversible and so any third-party +# callers don't break on upgrade. They are slated for removal in v0.2.0 +# along with the ``httpx`` dependency they bring in. +# --------------------------------------------------------------------------- + + def _parse_github_owner_repo(url: str) -> Tuple[str, str] | None: """Extract (owner, repo) from a GitHub remote URL. @@ -3183,74 +3200,35 @@ def download_and_extract_template( tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, + # DEPRECATED params (kept for source-compat; CLI no longer passes + # them as of v0.1.4 and they are slated for removal in v0.2.0). github_token: str = None, pre: bool = False, legacy_download: bool = False, ) -> Path: """Provision the workspace with scripts + command templates. - Two provisioning sources are supported: - - * **Bundle (default)** — copy from packaged assets shipped inside - ``rpgkit_cli`` (no network). Used whenever :func:`_assets.available` - is True and ``legacy_download`` is False. - * **Legacy release zip** — original behaviour: query GitHub API for - the latest matching release, download the per-AI/script-type zip, - and extract it. Activated by ``legacy_download=True`` or when the - bundle is unavailable (editable installs, etc.). + Bundle-only as of v0.1.4: templates are always sourced from the + packaged assets shipped inside ``rpgkit_cli/core_pack/``. To pick + up newer prompts the user upgrades the CLI itself (``uv tool + upgrade rpgkit-cli`` etc.), which ``rpgkit update`` does + automatically by default. - On ``--script ps`` the bundle path is rejected and the legacy path is - required. + The ``github_token`` / ``pre`` / ``legacy_download`` parameters and + the underlying ``_download_and_extract_release_zip`` path are kept + for now as dead code so the change is reversible, but they are no + longer reachable from the CLI surface. Returns ``project_path``. Uses the supplied :class:`StepTracker` to report progress when provided. """ - from . import _assets - - # ---- Decide provisioning source ---- - use_bundle = (not legacy_download) and _assets.available() - - # Bundle currently ships only POSIX-shell-flavoured scripts (today - # the scripts/ tree has no bash/ or powershell/ subdirs, so the - # CI's per-shell partitioning is vestigial. - # When the user explicitly asks for PowerShell, fall back to the - # legacy zip path so that future PowerShell variants in releases - # keep working. The notice is emitted through the tracker (when - # present) so it is actually visible during init/update. - if use_bundle and script_type == "ps": - use_bundle = False - notice = ( - "--script ps: falling back to release-zip download " - "(bundle ships POSIX-shell-oriented scripts only)" - ) - if tracker: - tracker.add("ps-fallback", "PowerShell fallback") - tracker.skip("ps-fallback", notice) - elif verbose: - console.print(f"[yellow]{notice}[/yellow]") - - if use_bundle: - return _install_from_bundle( - project_path, - ai_assistant, - script_type, - is_current_dir, - verbose=verbose, - tracker=tracker, - ) - - # Fall through to the original legacy zip path below. - return _download_and_extract_release_zip( + return _install_from_bundle( project_path, ai_assistant, script_type, is_current_dir, verbose=verbose, tracker=tracker, - client=client, - debug=debug, - github_token=github_token, - pre=pre, ) @@ -3382,6 +3360,9 @@ def _read_body(src: Path) -> str: (dest / f"rpgkit.{src.stem}.md").write_text(_read_body(src), encoding="utf-8") +# DEPRECATED: legacy release-zip provisioning path — no longer reachable +# from the CLI as of v0.1.4 (see top-of-file DEPRECATED block). Slated for +# removal in v0.2.0. def _download_and_extract_release_zip( project_path: Path, ai_assistant: str, @@ -3732,27 +3713,10 @@ def init( "--force", help="Force merge/overwrite when using --here (skip confirmation)", ), - skip_tls: bool = typer.Option( - False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)" - ), debug: bool = typer.Option( False, "--debug", - help="Show verbose diagnostic output for network and extraction failures", - ), - github_token: str = typer.Option( - None, - "--github-token", - help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)", - ), - pre: bool = typer.Option( - False, - "--pre", - help=( - "Download the latest pre-release (dev build) from GitHub. " - "Implies --legacy-download since bundle mode has no notion " - "of pre-release builds." - ), + help="Show verbose diagnostic output", ), no_mcp: bool = typer.Option( False, @@ -3771,16 +3735,6 @@ def init( "MCP config by hand." ), ), - legacy_download: bool = typer.Option( - False, - "--legacy-download", - help=( - "Bypass the packaged assets (bundle) and download the latest " - "release zip from GitHub instead. Use when you need prompts " - "newer than the installed CLI release ships, or when bundle " - "mode misbehaves. Implied by --pre." - ), - ), encode: Optional[bool] = typer.Option( None, "--encode/--no-encode", @@ -3809,8 +3763,8 @@ def init( This command will: 1. Check that required tools are installed (git is optional) 2. Let you choose your AI assistant - 3. Download the appropriate template from GitHub - 4. Extract the template to a new project directory or current directory + 3. Install command templates from the packaged bundle + 4. Place them into a new project directory or current directory 5. Initialize a fresh git repository (if not --no-git and no existing repo) 6. Optionally set up AI assistant commands @@ -3940,9 +3894,20 @@ def init( f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}" ) raise typer.Exit(1) + # PowerShell support is planned but not yet wired into the + # bundled templates / pipeline scripts. Reject explicit + # --script ps with a friendly message so users aren't surprised + # by missing files later. + if script_type == "ps": + console.print( + "[yellow]PowerShell (--script ps) is not yet supported and will " + "be added in a future release. Please use --script sh for now.[/yellow]" + ) + raise typer.Exit(1) selected_script = script_type else: - default_script = "ps" if os.name == "nt" else "sh" + # Default to sh on every platform until PowerShell templates land. + default_script = "sh" if sys.stdin.isatty(): selected_script = select_with_arrows( @@ -3967,7 +3932,7 @@ def init( tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) for key, label in [ - ("fetch", "Fetch latest pre-release" if pre else "Fetch latest release"), + ("fetch", "Install bundled templates"), ("download", "Download template"), ("extract", "Extract template"), ("zip-list", "Archive contents"), @@ -3992,15 +3957,6 @@ def init( ) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - verify = not skip_tls - local_ssl_context = ssl_context if verify else False - local_client = httpx.Client(verify=local_ssl_context) - - # --pre implies --legacy-download (bundle has no notion of - # pre-release builds; the user is asking for newer prompts - # than the installed CLI release ships). - effective_legacy = legacy_download or pre - download_and_extract_template( project_path, selected_ai, @@ -4008,11 +3964,7 @@ def init( here, verbose=False, tracker=tracker, - client=local_client, debug=debug, - github_token=github_token, - pre=pre, - legacy_download=effective_legacy, ) # .rpgkit/.source is written by whichever provisioning path @@ -4291,7 +4243,7 @@ def init( ver = _pkg_version("rpgkit-cli") except PackageNotFoundError: ver = "dev" - channel = "legacy" if legacy_download else "bundle" + channel = "bundle" script_label = script_type if script_type else "sh" ai_label = selected_ai if selected_ai else "?" if _inner_git.ensure_inner_git( @@ -4321,27 +4273,10 @@ def update( script_type: str = typer.Option( None, "--script", help="Script type to use: sh or ps" ), - skip_tls: bool = typer.Option( - False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)" - ), debug: bool = typer.Option( False, "--debug", - help="Show verbose diagnostic output for network and extraction failures", - ), - github_token: str = typer.Option( - None, - "--github-token", - help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)", - ), - pre: bool = typer.Option( - False, - "--pre", - help=( - "Download the latest pre-release (dev build) from GitHub. " - "Implies --legacy-download since bundle mode has no notion " - "of pre-release builds." - ), + help="Show verbose diagnostic output", ), no_mcp: bool = typer.Option( False, @@ -4360,15 +4295,6 @@ def update( "MCP config by hand." ), ), - legacy_download: bool = typer.Option( - False, - "--legacy-download", - help=( - "Bypass packaged assets and re-sync from the latest GitHub " - "release zip. Use when prompts in a release are newer than " - "the CLI you have installed. Implied by --pre." - ), - ), no_upgrade: bool = typer.Option( False, "--no-upgrade", @@ -4402,8 +4328,7 @@ def update( Examples: rpgkit update rpgkit update --ai claude - rpgkit update --pre - rpgkit update --github-token $GITHUB_TOKEN + rpgkit update --no-upgrade """ show_banner() @@ -4455,9 +4380,20 @@ def update( f"Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}" ) raise typer.Exit(1) + # PowerShell support is planned but not yet wired into the + # bundled templates / pipeline scripts. Reject explicit + # --script ps with a friendly message so users aren't surprised + # by missing files later. + if script_type == "ps": + console.print( + "[yellow]PowerShell (--script ps) is not yet supported and will " + "be added in a future release. Please use --script sh for now.[/yellow]" + ) + raise typer.Exit(1) selected_script = script_type else: - default_script = "ps" if os.name == "nt" else "sh" + # Default to sh on every platform until PowerShell templates land. + default_script = "sh" if sys.stdin.isatty(): selected_script = select_with_arrows( SCRIPT_TYPE_CHOICES, @@ -4597,7 +4533,7 @@ def update( tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) for key, label in [ - ("fetch", "Fetch latest pre-release" if pre else "Fetch latest release"), + ("fetch", "Install bundled templates"), ("download", "Download template"), ("extract", "Extract template"), ("zip-list", "Archive contents"), @@ -4618,20 +4554,6 @@ def update( ) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - verify = not skip_tls - local_ssl_context = ssl_context if verify else False - local_client = httpx.Client(verify=local_ssl_context) - - prior_source = _read_source_marker(project_path) - # Bundle is the default. Three things can flip us to legacy: - # 1. user passes --legacy-download explicitly - # 2. user passes --pre (no notion of bundle pre-releases) - # 3. workspace was previously provisioned from legacy and - # user has not overridden the channel - effective_legacy = ( - legacy_download or pre or prior_source == _SOURCE_LEGACY - ) - download_and_extract_template( project_path, selected_ai, @@ -4639,11 +4561,7 @@ def update( True, # is_current_dir — always merge/overwrite for update verbose=False, tracker=tracker, - client=local_client, debug=debug, - github_token=github_token, - pre=pre, - legacy_download=effective_legacy, ) # .rpgkit/.source is written by whichever provisioning path