Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1b26909
chore(sdd): validate-phase artifacts for pi-dotfiles
tstapler Sep 21, 2026
30c52e3
feat(pi-dotfiles): extension manifest schema and loader (Epic 1.1)
tstapler Sep 21, 2026
c1603ef
docs(pi-dotfiles): document Pi package artifact lifecycle spike (Epic…
tstapler Sep 21, 2026
2dab135
docs(pi-dotfiles): Claude tool compat matrix and global-instructions …
tstapler Sep 21, 2026
96ac3b4
feat(pi-dotfiles): notes-evidence check for approved manifest entries…
tstapler Sep 21, 2026
a378b96
feat(pi-dotfiles): manifest enforcement gate in llm-sync sync (Epic 1.2)
tstapler Sep 21, 2026
e98b4f1
feat(pi-dotfiles): fork-and-pin scaffolding helper, no approval path …
tstapler Sep 21, 2026
e9f8bec
docs(pi-dotfiles): fork-pin-review process skill (Epic 1.4)
tstapler Sep 21, 2026
f01d2cb
feat(pi-dotfiles): package ownership ledger for installed artifacts (…
tstapler Sep 21, 2026
14a4adc
feat(pi-dotfiles): first-party Claude tool-name compat shim (Epic 3.4)
tstapler Sep 21, 2026
8193819
docs(pi-dotfiles): document manifest gate and package ledger (Epic 5.4)
tstapler Sep 21, 2026
9efb836
docs(pi-dotfiles): staged-rollout and rollback runbook (Epic 5.1, Sto…
tstapler Sep 21, 2026
6d95324
docs(pi-dotfiles): reconcile compat matrix with shipped compat shim (…
tstapler Sep 21, 2026
a789c36
test(pi-dotfiles): verify rollback preserves credentials/sessions/tru…
tstapler Sep 21, 2026
ca8f420
test(pi-dotfiles): verify stale-package report line surfaces through …
tstapler Sep 21, 2026
45595dc
fix(pi-dotfiles): address /sdd:6-verify Layer 1+2 findings
tstapler Sep 22, 2026
83a7365
test(pi-dotfiles): Layer 4 UX verification for dry-run output surfaces
tstapler Sep 22, 2026
a60b1e2
fix(pi-dotfiles): address PR #52 code review findings (path traversal…
tstapler Sep 22, 2026
33f0f9a
Merge remote-tracking branch 'origin/master' into pi-dotfiles-implement
tstapler Sep 22, 2026
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
3 changes: 3 additions & 0 deletions .cfgcaddy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ links:
os: "Linux Darwin"
- src: .claude/CLAUDE.md
os: "Linux Darwin"
- src: .claude/CLAUDE.md
dest: .pi/agent/AGENTS.md
os: "Linux Darwin"
- src: .claude/agents
os: "Linux Darwin"
- src: .claude/agents.md
Expand Down
99 changes: 99 additions & 0 deletions .claude/skills/pi-extension-review/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
name: pi-extension-review
description: Fork-pin-review gate for adding any third-party Pi extension, package, or MCP source. Use before running fork_pin_extension.py, before hand-editing .config/pi/extensions-manifest.json, or whenever asked to add/enable a Pi extension not already wired via a first-party fragment.
---

# Pi Extension Fork-Pin-Review Gate

No third-party Pi extension, package, or MCP source may render into
`settings.json` without an approved manifest entry. This is enforced in
code, not just convention: `verify_pinned_sources_reviewed()` in
`stapler-scripts/llm-sync/src/sources/review_gate.py` cross-references every
rendered `tstapler`-fork-shaped source against
`.config/pi/extensions-manifest.json` and raises `PiConfigError` unless a
matching entry exists at the exact pinned commit with `disposition ==
"approved"`.

**Only a human hand-edits `disposition`, `approved_by`, and `approved_date`.**
No script or tool in this repo writes the literal string `"approved"` —
`fork_pin_extension.py` always writes `disposition: "candidate"` with
`approved_by`/`approved_date` left `null`, and `ManifestEntry.__post_init__`
in `stapler-scripts/llm-sync/src/sources/extension_manifest.py` raises
`ManifestError` if `disposition == "approved"` is ever constructed without
both approval fields present. Approval is a diff Tyler authors by hand, not
a flag any automation can set.

## Required gate before enabling any candidate

Reproduced from `project_plans/pi-dotfiles/extension-audit.md`'s "Required
gate before enabling any candidate" — complete all steps, in order, before
changing `disposition` to `"approved"`:

1. Fork the exact upstream repository to the Tyler-owned GitHub namespace.
2. Record upstream URL, upstream commit, fork commit, license, package
paths, and reviewer notes.
3. Inspect lifecycle scripts, network destinations, subprocess execution,
filesystem writes, environment access, secret handling, and telemetry.
4. Run upstream tests from the fork commit and add threat-focused
regression tests for the capabilities actually enabled.
5. Pin the Pi package source to the exact fork commit; mutable
branches/tags and upstream npm packages are rejected by `PiConfigSource`.
6. Introduce candidates disabled or in an opt-in fragment first, then
validate in a temporary Pi home before current macOS and Linux rollout.
7. Upgrades repeat this process; no automated upstream advancement.
8. Check maintenance liveness — last commit date, release cadence, and
contributor count — and record the finding in the manifest entry's
`notes` field. An unmaintained upstream must be caught here, not only
during the later capability review.

## How to run the helper + validator

`fork_pin_extension.py` scaffolds the mechanical parts of step 1 and 2 —
forking via `gh repo fork` and writing a `candidate` manifest entry — but
has no code path that can write `"approved"`:

```bash
# Preview the plan; makes no gh calls, writes nothing.
uv run --directory stapler-scripts/llm-sync scripts/fork_pin_extension.py fork \
<upstream-owner>/<upstream-repo> \
--id <stable-manifest-id> \
--capability "<comma,separated,capability,tags>" \
--dry-run

# Actually fork and write the candidate entry.
uv run --directory stapler-scripts/llm-sync scripts/fork_pin_extension.py fork \
<upstream-owner>/<upstream-repo> \
--id <stable-manifest-id> \
--capability "<comma,separated,capability,tags>"
```

This creates `github.com/tstapler/<repo>` and appends an entry to
`.config/pi/extensions-manifest.json` with `disposition: "candidate"`. From
there, steps 2-8 above are manual review work: hand-edit the entry's
`license`, `package_paths`, `reviewer`, `review_date`, and `notes` fields as
you complete each step, citing real file paths as evidence.

After any manifest or review-gate change, validate with:

```bash
make llm-sync-test
```

This runs every `stapler-scripts/llm-sync/test_*.py` module, including
`test_extension_manifest.py`, `test_review_gate.py`, and
`test_fork_pin_extension.py` — covering the approval-field invariant, the
gate's rejection of unreviewed/mismatched-commit/non-approved sources, and
the no-automated-approval guarantee.

## When a candidate fails review

Not every candidate reaches `approved`. If the review at any step above
turns up a blocker — unmaintained upstream, a capability that can't be
scoped safely, a security finding, or a reviewer decision to wait — set
`disposition` to `hold` (revisit later, evidence not yet conclusive) or
`rejected` (reviewed and declined). Both are legal terminal-for-now states
the manifest schema already supports; leaving a rejected or stalled
candidate at `disposition: "candidate"` misrepresents it as still pending
review. `verify_pinned_sources_reviewed()` blocks sync for `hold` and
`rejected` exactly as it does for `candidate` — only `approved` passes the
gate.
80 changes: 80 additions & 0 deletions .config/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ overlay's own tracked fragment can add its scope there because its
internally managed update channel owns that package's cadence. Local
package paths are also allowed.

Before forking, pinning, or enabling any third-party extension, follow the
fork-pin-review gate documented in
[`.claude/skills/pi-extension-review/SKILL.md`](../../.claude/skills/pi-extension-review/SKILL.md).

Never put credentials, OAuth state, sessions, trust data, or other mutable Pi
runtime state in these files.

Expand Down Expand Up @@ -85,3 +89,79 @@ uv run --directory stapler-scripts/llm-sync main.py --target pi --dry-run

A Pi-only target does not install Claude/Antigravity plugins or rewrite their
MCP settings.

## Package lifecycle

VERIFIED via a manual temp-`HOME` spike (pi 0.84.4, 2026-09-21), using the
real npm-hosted extension `@gotgenes/pi-permission-system` referenced in
`project_plans/pi-dotfiles/full-featured-profile-research.md`:

```bash
TMPHOME=$(mktemp -d)
mkdir -p "$TMPHOME/.pi/agent"
find "$TMPHOME" -type f | sort > before.txt # empty

HOME="$TMPHOME" pi install npm:@gotgenes/pi-permission-system --no-approve
find "$TMPHOME" -type f | sort > after-install.txt
```

**Artifact path**: an npm `packages` entry lands under a dedicated npm
workspace at `~/.pi/agent/npm/` — `package.json` (declares the package as a
`dependencies` entry), `package-lock.json`, a `.gitignore` (`*` /
`!.gitignore`), and the installed files under
`~/.pi/agent/npm/node_modules/<package>/` plus its own transitive
dependencies as npm siblings under the same `node_modules/`. `pi install`
also rewrites `~/.pi/agent/settings.json`'s `packages` key as a flat array of
source strings (e.g. `{"packages": ["npm:@gotgenes/pi-permission-system"]}`),
not the map-keyed-by-id object shape this file's own layered-config examples
above show — the map shape is this repo's authoring format, and `llm-sync`
must render it down to Pi's actual array-of-strings runtime shape. Installing
also seeds an ordinary npm cache at `~/.npm/` (`_cacache`, `_logs`), which is
npm's own global cache, not Pi-specific state.

**Removal via `pi remove`**: `HOME="$TMPHOME" pi remove
npm:@gotgenes/pi-permission-system --no-approve` fully prunes the artifact —
it empties `settings.json`'s `packages` array, updates
`~/.pi/agent/npm/package.json`/`package-lock.json`, and deletes
`~/.pi/agent/npm/node_modules/<package>/` along with any transitive
dependency no longer needed by another installed package. Nothing was left
behind or errored.

**Removal by hand-editing `settings.json` (the path `llm-sync` actually
takes) does *not* prune anything.** Given the artifact installed above, then
directly rewriting `settings.json`'s `packages` array to `[]` (simulating
`PiSettingsTarget.save()`) and re-running Pi — tried as `pi --version`,
`pi list --no-approve` (which reports "No packages installed" while the
files are still on disk), and a full non-interactive startup attempt
(`pi -p "hi" --offline --no-approve`, which fails only on missing API
credentials) — `~/.pi/agent/npm/node_modules/@gotgenes/pi-permission-system/`
remained on disk in every case. Pi does not garbage-collect installed
packages based on `settings.json` content alone; pruning only happens
through the explicit `pi remove <source>` (or `pi uninstall <source>`) CLI
path. This confirms the risk this plan's Rabbit Holes/Unresolved Questions
sections flagged as unverified: **Epic 2.2's `PiPackageLedger` is required**
for stale-artifact cleanup — Pi will not do it on its own from a rendered
`settings.json`.

**Caution for Epic 2.2 tooling**: bare `pi update` (no source argument)
self-updates the `pi` binary itself via `npm --prefix ~/.local`, outside any
temp `HOME` sandboxing (it updated the real system-wide install during this
spike, from the repo-pinned 0.84.4 to 0.86.1; reverted with `npm install
--global --prefix ~/.local --no-audit --no-fund
"@earendil-works/pi-coding-agent@0.84.4"`). Any future reconciliation
tooling must never invoke bare `pi update` — use `pi update --extensions` or
target specific sources.

## Package ownership ledger

`llm-sync` tracks which Pi package artifacts it installed in a ledger at
`~/.config/llm-sync/pi-package-state.json` (override with
`--pi-package-ledger-state-file`), since hand-editing `settings.json` alone
doesn't prune anything (see Package lifecycle above).

- `--prune-stale-pi-packages` — a package the ledger owns but that's no
longer enabled in the rendered config is always reported as stale; this
flag actually deletes its on-disk artifact.
- `--reconcile-pi-package-ledger` — rebuilds ledger entries missing from a
prior sync that was interrupted between writing `settings.json` and
writing the ledger, from the current config's enabled packages.
7 changes: 7 additions & 0 deletions .config/pi/config.d/40-claude-compat.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extensions": {
"claude-compat": {
"path": "~/dotfiles/plugins/pi-claude-compat/pi/index.ts"
}
}
}
1 change: 1 addition & 0 deletions .config/pi/extensions-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"extensions": {}}
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ on:
- 'bootstrap/**'
- 'install.sh'
- '.github/workflows/ci.yml'
- 'stapler-scripts/llm-sync/**'
- '.config/pi/**'
pull_request:
paths:
- '**/*.py'
Expand All @@ -19,6 +21,8 @@ on:
- 'bootstrap/**'
- 'install.sh'
- '.github/workflows/ci.yml'
- 'stapler-scripts/llm-sync/**'
- '.config/pi/**'

jobs:
ansible-lint:
Expand Down Expand Up @@ -54,6 +58,8 @@ jobs:
run: |
cd stapler-scripts/slack-emoji-export
uv run pytest test_extract_slack_emoji.py
- name: Run llm-sync tests
run: make llm-sync-test
- name: Run ruff
run: |
cd stapler-scripts/slack-emoji-export
Expand Down
4 changes: 4 additions & 0 deletions bootstrap-pyinfra/deploys/llm_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@ def llm_sync() -> None:
if code != 0:
raise DeployError(f"llm-sync failed: {output}")

# shell_capture returns main.py's combined stdout+stderr verbatim (see
# its docstring), so this already reproduces PiPackageLedger's
# `stale Pi package: ...` report lines unmodified -- no pyinfra-side
# change needed to surface them (Epic 2.2, Task 2.2.2a).
print(output)
149 changes: 149 additions & 0 deletions bootstrap-pyinfra/test_llm_sync_deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Verifies the pass-through claim in deploys/llm_sync.py's docstring: that
main.py's `stale Pi package: ...` report line survives unmodified through
`common.shell_capture`, the mechanism `llm_sync()` uses to invoke main.py.

`llm_sync()` itself can't be called directly here: it's a pyinfra
`@deploy`-decorated function that calls `shell_capture`, which calls
`host.get_fact()`, and that only resolves inside a connected pyinfra run --
not a plain `uv run` test process (the same reason test_pi_install.py only
tests the pure functions in deploys/pi.py, never the `@deploy("Pi")`-decorated
`pi()` itself). So these tests reproduce llm_sync()'s actual invocation shape
via subprocess instead: driving main.py's real CLI entrypoint through `uv
run` (the same tool llm_sync()'s shell command uses), against a fixture
matching stapler-scripts/llm-sync/test_pi_package_ledger.py's
`_seed_environment`/`_base_args` pattern for a single stale ledger entry.

Run directly: uv run test_llm_sync_deploy.py
"""

import json
import subprocess
import tempfile
from pathlib import Path

LLM_SYNC_DIR = Path(__file__).parent.parent / "stapler-scripts" / "llm-sync"

_STALE_LINE = (
"stale Pi package: old-extension (not pruned; run with --prune-stale-pi-packages)"
)

# Marker string common.shell_capture uses to split captured output from the
# trailing exit code it appends via `printf "%s{marker}%d" "$OUT" "$CODE"`.
_SHELL_CAPTURE_MARKER = "__PYINFRA_EXIT__"


def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value), encoding="utf-8")


def _seed_environment(root: Path) -> Path:
"""Same fixture shape as test_pi_package_ledger.py's `_seed_environment`:
no packages enabled in config, plus a pre-existing stale ledger entry
named `old-extension` whose recorded artifact path is a real directory.
"""
_write_json(root / "config.d" / "10-empty.json", {"packages": {}})
_write_json(root / "extensions-manifest.json", {"extensions": {}})

artifact_dir = root / "agent" / "npm" / "node_modules" / "old-extension"
artifact_dir.mkdir(parents=True)
(artifact_dir / "index.js").write_text("// stale", encoding="utf-8")

_write_json(root / "package-state.json", {"old-extension": str(artifact_dir)})
return artifact_dir


def _main_py_args(root: Path) -> list[str]:
"""The subset of main.py's fixture-pointing flags needed to reach the
stale-report code path without touching this machine's real ~/.claude,
~/.pi, or ~/.config/pi state."""
return [
"main.py",
"--target",
"pi",
"--source-dir",
str(root / "claude_src"), # empty/nonexistent: no real Claude assets synced
"--state-file",
str(root / "state.json"),
"--pi-dir",
str(root / "agent"),
"--pi-config-file",
str(root / "config.json"),
"--pi-config-dir",
str(root / "config.d"),
"--pi-local-config",
str(root / "config.local.json"),
"--pi-local-config-dir",
str(root / "config.local.d"),
"--pi-extensions-manifest",
str(root / "extensions-manifest.json"),
"--pi-settings-file",
str(root / "agent" / "settings.json"),
"--pi-settings-state-file",
str(root / "settings-state.json"),
"--pi-package-ledger-state-file",
str(root / "package-state.json"),
]


def test_llm_sync_deploy_prints_stale_pi_package_report_line() -> None:
"""main.py, invoked through its real CLI entrypoint via `uv run` (the
same tool llm_sync()'s shell command uses) rather than calling
sync_pi_settings() in-process, reaches the stale-report code path and
prints the byte-exact line."""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_seed_environment(root)

result = subprocess.run(
["uv", "run", *_main_py_args(root)],
cwd=LLM_SYNC_DIR,
capture_output=True,
text=True,
timeout=60,
)

assert result.returncode == 0, result.stdout + result.stderr
assert _STALE_LINE in result.stdout.splitlines()


def test_bootstrap_llm_sync_reproduces_exact_stale_report_line() -> None:
"""Reproduces `common.shell_capture`'s exact command shape --
`OUT=$(command 2>&1); CODE=$?; printf "%s{marker}%d" "$OUT" "$CODE"`,
then splitting on the marker the same way shell_capture does -- to prove
the specific pass-through mechanism llm_sync() relies on (combining
stdout+stderr through command substitution) doesn't truncate, reorder,
or otherwise mangle the stale-report line."""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_seed_environment(root)

inner_command = "uv run " + " ".join(_main_py_args(root))
wrapped = (
f'OUT=$({inner_command} 2>&1); CODE=$?; '
f'printf "%s{_SHELL_CAPTURE_MARKER}%d" "$OUT" "$CODE"'
)

result = subprocess.run(
["bash", "-c", wrapped],
cwd=LLM_SYNC_DIR,
capture_output=True,
text=True,
timeout=60,
)

assert result.returncode == 0, result.stdout + result.stderr
captured_output, _, exit_code = result.stdout.rpartition(
_SHELL_CAPTURE_MARKER
)

assert exit_code == "0", result.stdout
assert _STALE_LINE in captured_output.splitlines()


if __name__ == "__main__":
tests = [value for key, value in list(globals().items()) if key.startswith("test_")]
for test in tests:
test()
print(f"ok {test.__name__}")
print(f"\n{len(tests)} checks passed")
Loading
Loading