Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/pi-resources.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
106 changes: 106 additions & 0 deletions bin/sync-pi-resources
Original file line number Diff line number Diff line change
@@ -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 = "<!-- dotfiles:pi-builder:start -->"
END = "<!-- dotfiles:pi-builder: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: "<task>"\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()
5 changes: 5 additions & 0 deletions bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions bootstrap/tools.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
29 changes: 29 additions & 0 deletions docs/pi.md
Original file line number Diff line number Diff line change
@@ -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-<filename> <task>` 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.
3 changes: 3 additions & 0 deletions docs/radar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 18 additions & 10 deletions scripts/test-bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.")
Expand Down
Loading
Loading