diff --git a/.github/workflows/pi-resources.yml b/.github/workflows/pi-resources.yml new file mode 100644 index 00000000..1416dd6f --- /dev/null +++ b/.github/workflows/pi-resources.yml @@ -0,0 +1,55 @@ +name: Pi shared resources + +on: + pull_request: + paths: + - 'bin/sync-pi-resources' + - 'bin/sync-codex-skills' + - 'scripts/install-software-standard.py' + - 'scripts/test-pi-resources.py' + - 'scripts/test-bootstrap.py' + - 'ai/agents/**' + - 'ai/skills/builder/**' + - 'Makefile' + - 'bootstrap.sh' + - '.github/workflows/pi-resources.yml' + push: + branches: [master] + paths: + - 'bin/sync-pi-resources' + - 'bin/sync-codex-skills' + - 'scripts/install-software-standard.py' + - 'scripts/test-pi-resources.py' + - 'scripts/test-bootstrap.py' + - 'ai/agents/**' + - 'ai/skills/builder/**' + - 'Makefile' + - 'bootstrap.sh' + - '.github/workflows/pi-resources.yml' + +permissions: + contents: read + +jobs: + resources: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install the supported Pi runtime + run: npm install --global --ignore-scripts @earendil-works/pi-coding-agent@1.4.0 + - name: Discover the shared catalog through native Pi + run: python3 scripts/test-pi-resources.py --native + - name: Exercise the opt-in bootstrap workflow + if: runner.os == 'Linux' + run: python3 scripts/test-bootstrap.py diff --git a/Makefile b/Makefile index f7b8f278..33edd848 100644 --- a/Makefile +++ b/Makefile @@ -105,7 +105,7 @@ firstmate-config: treehouse-config zellij-config @bash "$(MAKE_SCRIPTS)/config.sh" "$@" "$(DOTFILES)" "$(HOME)" "$(UV)" "$(UNAME_S)" "$(FIRSTMATE_HOME)" .PHONY: pi-config firstmate-mobile-config test-firstmate-github -pi-config: +pi-config: pi-resources @bash "$(MAKE_SCRIPTS)/config.sh" "$@" "$(DOTFILES)" "$(HOME)" "$(UV)" "$(UNAME_S)" firstmate-mobile-config: firstmate-config pi-config @@ -469,3 +469,11 @@ epenv: ep-direnv .PHONY: test-ssh-agent test-ssh-agent: @python3 "$(DOTFILES)/scripts/test-ssh-agent.py" + +# Pi shares the existing skill catalog without a second copy. +.PHONY: pi pi-resources test-pi-resources +pi: pi-resources +pi-resources: codex-skills + @python3 "$(DOTFILES)/bin/sync-pi-resources" --source "$(DOTFILES)/ai" --home "$(HOME)" +test-pi-resources: + @python3 "$(DOTFILES)/scripts/test-pi-resources.py" diff --git a/bin/sync-pi-resources b/bin/sync-pi-resources new file mode 100755 index 00000000..41ca8c7c --- /dev/null +++ b/bin/sync-pi-resources @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Expose shared role instructions and Builder guidance to Pi, preserving local files.""" + +import argparse +import json +from pathlib import Path +import re +import sys + + +MARKER = "---\n# Generated by dotfiles sync-pi-resources.\n" +START = "" +END = "" +GUIDANCE = f"""{START} +# Default software guidance: Builder + +For software design, implementation, modification, debugging, and review, read `~/.local/share/software-standards/builder/references/instructions.md` and follow the Builder skill, architecture standard, and construction policy it identifies. +{END} +""" + + +def with_guidance(original, path): + if (original.count(START) != original.count(END) + or original.count(START) > 1 + or START in original and original.index(START) > original.index(END)): + raise ValueError(f"malformed managed guidance block: {path}") + if START in original: + before, rest = original.split(START, 1) + _, after = rest.split(END, 1) + return before + GUIDANCE.rstrip("\n") + after + return GUIDANCE + "\n" + original + + +def template(source): + description = f"Use the shared {source.stem} role in this Pi conversation" + return (MARKER + f"description: {json.dumps(description)}\n" + 'argument-hint: ""\n---\n\n' + f"Read the shared role instructions at {json.dumps(str(source))} and use them for the task below. " + "This is role guidance in the current conversation, not a separate agent. " + "Claude frontmatter does not select a Pi model or enforce tool permissions; " + "honor the role's intended limits with the available tools and report any unavailable capabilities.\n\n" + "Task: $ARGUMENTS\n") + + +def sync(source, home): + source = source.resolve() + agents = source / "agents" + if not agents.is_dir(): + raise ValueError(f"missing shared agent tree: {agents}") + standard = home / ".local/share/software-standards/builder/references/instructions.md" + if not standard.is_file(): + raise ValueError("Builder is not installed; run make pi-config") + directory = home / ".pi/agent" + prompts = directory / "prompts" + if prompts.is_symlink(): + raise ValueError(f"preserving linked prompt directory: {prompts}") + guidance = directory / "AGENTS.md" + for name in ("AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"): + candidate = directory / name + if candidate.exists() or candidate.is_symlink(): + guidance = candidate + break + if guidance.is_symlink(): + raise ValueError(f"preserving linked instructions: {guidance}") + original = guidance.read_text() if guidance.exists() else "" + updated = with_guidance(original, guidance) + templates = {} + for path in sorted(agents.glob("*.md")): + if not path.is_file(): + print(f"sync-pi-resources: unavailable source {path}, skipping", file=sys.stderr) + continue + if not re.fullmatch(r"[A-Za-z0-9_-]+", path.stem): + raise ValueError(f"invalid prompt name: {path.stem}") + templates[f"dotfiles-{path.stem}.md"] = template(path) + + prompts.mkdir(parents=True, exist_ok=True) + for name, content in templates.items(): + target = prompts / name + if target.is_symlink() or (target.exists() and + (not target.is_file() or not target.read_text().startswith(MARKER))): + print(f"sync-pi-resources: preserving local prompt {target}", file=sys.stderr) + continue + if not target.exists() or target.read_text() != content: + target.write_text(content) + for target in prompts.glob("dotfiles-*.md"): + if (target.name not in templates and not target.is_symlink() + and target.is_file() and target.read_text().startswith(MARKER)): + target.unlink() + if updated != original: + guidance.write_text(updated) + print(f"sync-pi-resources: shared roles in {prompts}; guidance in {guidance}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=Path(__file__).resolve().parents[1] / "ai") + parser.add_argument("--home", type=Path, default=Path.home()) + args = parser.parse_args() + try: + sync(args.source.expanduser().absolute(), args.home.expanduser().absolute()) + except (OSError, ValueError) as error: + parser.exit(1, f"sync-pi-resources: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/bootstrap.sh b/bootstrap.sh index 00de6bb1..f7924a4b 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -751,6 +751,11 @@ install_claude-code_from_release() ( bash "$claude_stage" "$1" || exit 1 ) +install_pi_from_release() { + install_package_version node 24 || return 1 + npm install -g --ignore-scripts "@earendil-works/pi-coding-agent@$1" +} + install_stylua_from_release() { install_package_version node 24 npm install -g "@johnnymorganz/stylua-bin@$1" diff --git a/bootstrap/tools.toml b/bootstrap/tools.toml index 56fc7f48..4a3223d3 100644 --- a/bootstrap/tools.toml +++ b/bootstrap/tools.toml @@ -237,8 +237,8 @@ requires = ["treehouse", "jq", "node"] setup = ["firstmate-config"] [tools.pi] -installer = "npm" -package = "@earendil-works/pi-coding-agent" +installer = "package" +package = "pi" version = "1.4.0" requires = ["node"] setup = ["pi-config"] diff --git a/docs/pi.md b/docs/pi.md new file mode 100644 index 00000000..6715a964 --- /dev/null +++ b/docs/pi.md @@ -0,0 +1,29 @@ +# Pi trial + +[Pi](https://github.com/earendil-works/pi) is an optional coding-agent runtime. The dotfiles integration shares the existing Builder standard, skills, and agent instructions. It does not change the default coding client. + +## Install + +Select `pi = true` under `[tools]` in the machine TOML and run `make install` to install `@earendil-works/pi-coding-agent` at version 1.4.0 or retain an already installed newer version, then configure its shared resources. Bootstrap installs Node 24 first. The Pi package is installed with npm lifecycle scripts disabled. + +If Pi is already installed, run `make pi` to share resources. `make pi-config` also applies the existing Firstmate Pi settings. These configuration targets do not download Pi. Authenticate and select a provider/model using Pi's own setup. The shared-resource synchronizer does not copy credentials, configure providers, or edit Pi's settings; `make pi-config` and the selected installation also apply the existing Firstmate settings. + +This integration uses Pi's default `~/.pi/agent` directory. If you set `PI_CODING_AGENT_DIR`, its resources must be configured separately. + +## Shared resources + +| Source | Pi interface | +| --- | --- | +| Existing `ai/skills/` catalog | `~/.agents/skills/dotfiles`, already used by Codex and discovered by Pi | +| Shared Builder package | A managed guidance block in Pi's active user context file | +| `ai/agents/*.md` | `/dotfiles- ` prompt templates | + +For example, `/dotfiles-code-reviewer review the current diff` asks the current Pi conversation to read and follow `ai/agents/code-reviewer.md`. Templates point at the shared files, so role edits are available without maintaining a separate Pi copy. Run `make pi` after adding or removing roles or changing the shared skill catalog, then `/reload` in running Pi sessions. + +The templates are role prompts in the current conversation. They do not create subagents, choose models, or enforce the Claude tool names in agent frontmatter. In particular, the reviewer template does not restrict an otherwise writable Pi session. A runtime adapter must supply a restricted tool set when that boundary is required. Skills that depend on Claude-specific hooks or APIs also need an adapter before those capabilities work in Pi. + +Local Pi prompts are preserved, including collisions with the `dotfiles-` names. Only generated templates with the installer's marker are refreshed or removed. Existing user guidance is retained around the managed Builder block; linked instruction files are left alone with an actionable error. The existing shared skill synchronizer retains its platform exclusions and standalone Builder package. + +## Verification boundary + +`make test-pi-resources` exercises the real configuration target in an isolated home. CI also uses Pi 1.4.0 to discover the installed resources without making a model request. Resource discovery does not establish that a model follows every shared instruction or that Claude-specific skills work unchanged. diff --git a/docs/radar.md b/docs/radar.md index f9cd63ed..fb2d2a42 100644 --- a/docs/radar.md +++ b/docs/radar.md @@ -29,6 +29,9 @@ Tiling window manager via the macOS Accessibility API. Manages all windows at th ## Trial +### Pi + +[Pi](pi.md): an opt-in composable agent runtime that reuses the existing shared skills, Builder guidance, and role instructions. Start with bounded workflows and keep the established coding clients available. Revisit adoption after exercising supervision, review results, and status reporting in normal work. ### Firstmate through Paseo and GitHub [Firstmate mobile workflow](firstmate-mobile.md): one Paseo conversation coordinates work, while assigned Pi sessions handle GitHub feedback directly through native Firstmate task records and steering inboxes. This replaces the standalone Claude dispatchers. Keep it opt-in pending live Bedrock/provider, upstream supervision, and phone reconnect evidence described in [#134](https://github.com/lanej/dotfiles/issues/134). diff --git a/scripts/test-bootstrap.py b/scripts/test-bootstrap.py index fe448d59..2865b416 100644 --- a/scripts/test-bootstrap.py +++ b/scripts/test-bootstrap.py @@ -222,15 +222,17 @@ def record(event): elif name == "npm" and args[:1] == ["list"]: print('{"dependencies": {"yaml-language-server": {"version": "99.0.0"}}}') elif name == "npm" and args[:2] == ["install", "-g"]: - package, version = args[2].rsplit("@", 1) - assert package == "@openai/codex", args + package, version = args[-1].rsplit("@", 1) + tool = {"@openai/codex": "codex", "@earendil-works/pi-coding-agent": "pi"}[package] + if tool == "pi": + assert args[2] == "--ignore-scripts", args node_bin = Path(shutil.which("node")).parent - (node_bin / "codex").symlink_to(fixture / "driver") - (home / "codex-version").write_text(version) - record("codex") -elif name in ("codex", "claude"): + (node_bin / tool).symlink_to(fixture / "driver") + (home / (tool + "-version")).write_text(version) + record(tool) +elif name in ("codex", "claude", "pi"): assert args == ["--version"], args - print(name + " " + (home / (name + "-version")).read_text().strip()) + print(("" if name == "pi" else name + " ") + (home / (name + "-version")).read_text().strip()) elif name == "jq" and args[:1] == ["-r"]: print("99.0.0") elif name in ("eza", "yq"): @@ -252,6 +254,9 @@ def record(event): # These integrations configure their already-installed tools before the # full environment is ready; the final make below checks all runtimes. sys.exit(0) +elif name == "make" and args[-1:] == ["pi-config"]: + assert subprocess.check_output(["pi", "--version"], text=True).strip() == "1.4.0" + record("pi config") elif name == "make" and args[-1:] == ["base"]: subprocess.run(["/usr/bin/make", *args], check=True) record("base") @@ -394,7 +399,7 @@ def record(event): # Keep this fresh-box workflow independent of tools preinstalled on CI. system_bin = home / "system-bin" system_bin.mkdir() - missing_tools = {"python3", "tmux", "eza", "yq", "btop", "rustup", "convert", "magick", "imagemagick", "treehouse"} + missing_tools = {"python3", "tmux", "eza", "yq", "btop", "pi", "zellij", "rustup", "convert", "magick", "imagemagick", "treehouse"} for system_dir in (Path("/usr/bin"), Path("/bin")): for tool in system_dir.iterdir(): destination = system_bin / tool.name @@ -434,7 +439,7 @@ def record(event): # Stage two installs the configured workstation on ARM Linux, including # an older Neovim that needs a source build and missing eza/yq/btop. - machine_config.write_text('sets = ["workstation", "easypost"]\n[tools]\nmermaid = false\n') + machine_config.write_text('sets = ["workstation", "easypost"]\n[tools]\nmermaid = false\npi = true\n') plan = subprocess.run( ["/usr/bin/make", "--no-print-directory", "plan"], cwd=checkout, env=env, capture_output=True, text=True, @@ -460,7 +465,7 @@ def record(event): assert shadow_node.is_symlink() and shadow_node.resolve() == driver.resolve() events = (home / "events").read_text().splitlines() assert events.index("make") > max( - events.index(tool) for tool in ("uv", "node", "python", "rustup", "codex", "claude") + events.index(tool) for tool in ("uv", "node", "python", "rustup", "codex", "claude", "pi") ) assert "neovim setup" in events assert "neovim build" in events @@ -540,12 +545,15 @@ def record(event): # and skip both the source install and Neovim's post-install setup. machine_config.write_text(machine_config.read_text() + "neovim = false\n") (home / ".local/bin/nvim").unlink() + (home / "pi-version").write_text("1.5.0") previous_events = len((home / "events").read_text().splitlines()) reduced = subprocess.run(["/bin/bash", "./bootstrap.sh", "install"], cwd=checkout, env=env, capture_output=True, text=True) assert reduced.returncode == 0, reduced.stdout + reduced.stderr new_events = (home / "events").read_text().splitlines()[previous_events:] assert "neovim build" not in new_events and "neovim setup" not in new_events + assert "pi" not in new_events + assert (home / "pi-version").read_text() == "1.5.0" assert "node" not in new_events and "zellij" not in new_events assert not (home / ".local/bin/nvim").exists() print("PASS: minimal bootstrap provides the native baseline and runtime; explicit installation adds selected tools and private Bedrock credentials.") diff --git a/scripts/test-pi-resources.py b/scripts/test-pi-resources.py new file mode 100644 index 00000000..7d7c6db5 --- /dev/null +++ b/scripts/test-pi-resources.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""One shared-resource installation workflow; --native also checks Pi discovery.""" + +import json +import os +from pathlib import Path +import queue +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def commands(home): + pi = shutil.which("pi") + if not pi: + raise RuntimeError("Install Pi 1.4.0 before running --native") + agent = home / ".pi/agent" + (agent / "models.json").write_text(json.dumps({"providers": {"resource-fixture": { + "baseUrl": "http://127.0.0.1:9/v1", "api": "openai-completions", + "apiKey": "fixture", "models": [{"id": "tiny"}]}}})) + env = dict(os.environ, HOME=str(home), PI_CODING_AGENT_DIR=str(agent), + PI_SKIP_VERSION_CHECK="1", PI_TELEMETRY="0") + with tempfile.TemporaryFile(mode="w+") as errors: + process = subprocess.Popen( + [pi, "--mode", "rpc", "--offline", "--no-session", "--no-extensions", "--no-mcp", + "--model", "resource-fixture/tiny"], + cwd=home, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=errors, text=True, + ) + events = queue.Queue() + def read(): + for line in process.stdout: + events.put(line) + events.put(None) + threading.Thread(target=read, daemon=True).start() + try: + process.stdin.write('{"id":"catalog","type":"get_commands"}\n') + process.stdin.flush() + while True: + line = events.get(timeout=20) + if line is None: + errors.seek(0) + raise AssertionError("Pi exited before returning resources: " + errors.read()) + event = json.loads(line) + if event.get("id") == "catalog": + if not event.get("success"): + raise AssertionError(event) + return event["data"]["commands"] + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + process.stdin.close() + process.stdout.close() + + +class SharedResourceWorkflow(unittest.TestCase): + def test_shared_roles_and_skills_survive_refresh_with_local_guidance(self): + with tempfile.TemporaryDirectory(prefix="pi resources ") as temporary: + home = Path(temporary).resolve() + checkout = home / "checkout" + for relative in ("bin/sync-codex-skills", "bin/sync-pi-resources", + "scripts/install-software-standard.py", + "ai/agents/code-reviewer.md", "ai/agents/reflection.md", + "ai/agents/github-pr-writer.md"): + target = checkout / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / relative, target) + shutil.copytree(ROOT / "ai/skills/builder", checkout / "ai/skills/builder") + agent = home / ".pi/agent" + prompts = agent / "prompts" + prompts.mkdir(parents=True) + guidance = agent / "AGENTS.md" + guidance.write_text("Keep my local project conventions.\n") + local = prompts / "dotfiles-github-pr-writer.md" + local.write_text("My personal PR prompt.\n") + settings = agent / "settings.json" + settings.write_text('{"theme":"dark"}\n') + def install(): + result = subprocess.run( + ["make", "--no-print-directory", "-f", str(ROOT / "Makefile"), + "pi", f"DOTFILES={checkout}", f"HOME={home}"], + cwd=checkout, capture_output=True, text=True, timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + install() + shared = home / ".agents/skills/dotfiles/builder/SKILL.md" + self.assertEqual(shared.resolve(), home / ".local/share/software-standards/builder/SKILL.md") + role = prompts / "dotfiles-code-reviewer.md" + self.assertIn(str(checkout / "ai/agents/code-reviewer.md"), role.read_text()) + self.assertIn("$ARGUMENTS", role.read_text()) + self.assertTrue((prompts / "dotfiles-reflection.md").is_file()) + (checkout / "ai/agents/reflection.md").unlink() + install() + self.assertFalse((prompts / "dotfiles-reflection.md").exists()) + self.assertEqual(local.read_text(), "My personal PR prompt.\n") + self.assertEqual(settings.read_text(), '{"theme":"dark"}\n') + self.assertIn("Keep my local project conventions.", guidance.read_text()) + self.assertEqual(guidance.read_text().count("# Default software guidance: Builder"), 1) + if NATIVE: + catalog = commands(home) + roles = [c for c in catalog if c["name"] == "dotfiles-code-reviewer"] + self.assertEqual(len(roles), 1, catalog) + self.assertEqual(roles[0]["source"], "prompt") + self.assertEqual(Path(roles[0]["sourceInfo"]["path"]), role) + skills = [c for c in catalog if c["name"] == "skill:builder"] + self.assertEqual(len(skills), 1, catalog) + self.assertEqual(skills[0]["source"], "skill") + + +if __name__ == "__main__": + NATIVE = "--native" in sys.argv + if NATIVE: + sys.argv.remove("--native") + unittest.main() diff --git a/scripts/test-zellij-sessions.py b/scripts/test-zellij-sessions.py index 169c31b3..d914e908 100644 --- a/scripts/test-zellij-sessions.py +++ b/scripts/test-zellij-sessions.py @@ -95,9 +95,35 @@ def open_popup(terminal): terminal.until("popup renders its ready shell", lambda: f"{POPUP}:zellij-ready> ".encode() in terminal.output[rendered:]) # Confirm the guest through the CLI, then its host's nesting-focus indication. - terminal.until("popup client attaches to its ready shell", lambda: - bool(terminal.action("list-clients", session=POPUP).strip().splitlines()[1:]) - and "zellij-ready>" in terminal.action("dump-screen", session=POPUP)) + try: + terminal.until("popup client attaches to its ready shell", lambda: + bool(terminal.action("list-clients", session=POPUP).strip().splitlines()[1:]) + and "zellij-ready>" in terminal.action("dump-screen", session=POPUP)) + except AssertionError as error: + # Keep the failing predicate intact. Snapshot both halves separately, + # then compare the default dump with explicit terminal panes before + # cleanup removes the isolated session. + def snapshot(*args): + try: + return terminal.action(*args, session=POPUP) + except Exception as diagnostic_error: + return f"Diagnostic failed: {diagnostic_error!r}" + + observations = { + "list-clients": snapshot("list-clients"), + "default dump-screen": snapshot("dump-screen"), + "list-panes": snapshot("list-panes", "--json"), + } + try: + panes = json.loads(observations["list-panes"]) + observations["terminal dumps"] = { + str(pane["id"]): snapshot("dump-screen", "--pane-id", str(pane["id"])) + for pane in panes if not pane["is_plugin"] + } + except Exception as diagnostic_error: + observations["terminal dumps error"] = repr(diagnostic_error) + raise AssertionError(f"{error}\nPopup readiness snapshot:\n" + f"{json.dumps(observations, indent=2)}") from error # The shell can render before native nesting registration finishes. Wait # for its acknowledged host before sending keys intended for the guest. def nested_ready():