chore(agents): track .pi config, add AGENTS.md symlink and pi format-on-edit - #433
Conversation
…on-edit Prepare the repo for Claude Code and pi agents at parity: - Track .pi/extensions/: protect-build-artifacts.ts (was untracked) plus a new format-on-edit.ts that mirrors .claude/hooks/format-on-edit.sh, running after write/edit on .rs files (best-effort, never blocks the agent on a formatting failure). - Add AGENTS.md as a symlink to CLAUDE.md so tools that look for AGENTS.md (pi, Cursor, etc.) find the same instructions Claude Code already loads. - Document the shared agent setup in CONTRIBUTING.md.
- format-on-edit.ts: use event.input.path (not event.input?.path) to match protect-build-artifacts.ts. Confirmed non-optional in pi's ToolResultEventBase (Record<string, unknown>). - CONTRIBUTING.md: clarify that only shared .claude/ config is tracked; per-user scratch (settings.local.json, worktrees/) is gitignored. - Verified at runtime: both extensions import + subscribe to the correct events; handler filters .rs/isError/write|edit correctly; end-to-end run on a misformatted .rs file invoked rustfmt --edition 2024 and reformatted it.
There was a problem hiding this comment.
Pull request overview
Aligns the pi coding agent’s project-local configuration with the existing Claude Code setup so both agents share the same instructions source and post-edit Rust formatting behavior.
Changes:
- Adds/commits pi agent extensions for build-artifact protection and Rust format-on-edit.
- Documents shared vs. per-user agent configuration in
CONTRIBUTING.md. - Introduces pi-side rustfmt formatting hook triggered after
write/editresults.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| CONTRIBUTING.md | Adds an “Agent setup” section describing shared tracked agent config and per-user scratch. |
| .pi/extensions/protect-build-artifacts.ts | New pi extension to block agent edits to certain protected paths. |
| .pi/extensions/format-on-edit.ts | New pi extension to run rustfmt --edition 2024 after successful Rust edits. |
Suppressed comments (1)
.pi/extensions/protect-build-artifacts.ts:31
- The block reason always suggests
make -C src generated, but this guard also blockstarget/andCargo.lock, where that guidance is inaccurate. Consider tailoring the message based on which pattern matched (or making it generic).
if (PROTECTED.some((p) => path.includes(p))) {
ctx.ui.notify?.(`Blocked write to build artifact: ${path}`, "warning");
return { block: true, reason: `"${path}" is a generated/build artifact; regenerate via \`make -C src generated\` instead of editing` };
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
protect-build-artifacts.ts:
- Remove codegen/ from PROTECTED. It is the hand-written generator crate
(src/codegen/src/main.rs), not generated output; CLAUDE.md lists it as the
tonic_build script. Only its output crate src/generated is protected.
The old comment ('regenerate, don't hand-edit') did not apply to it.
- Resolve tool paths to absolute before matching so relative inputs like
'target/debug/...' are caught by '/target/' (previously only matched
absolute paths).
- Tailor the block reason per pattern instead of always suggesting
'make -C src generated' (which was inaccurate for target/ and Cargo.lock).
format-on-edit.ts:
- Surface rustfmt failures as a notification, matching the header comment
(previously spawn errors and non-zero exits were swallowed silently).
Suppresses the notification when ctx.signal aborted (turn cancelled,
not a real formatting failure).
Verified: load + subscribe OK; protect now blocks relative target/, allows
codegen/, returns tailored reasons; format-on-edit notifies on non-zero
exit and ENOENT, stays silent on success and on abort.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
CONTRIBUTING.md:21
AGENTS.mdis referenced here as an existing symlink toCLAUDE.md, but there is noAGENTS.mdin this PR checkout. Either add the symlink as part of the PR (to match the description) or adjust this line so CONTRIBUTING doesn’t claim a file that isn’t present.
- `CLAUDE.md` (also symlinked as `AGENTS.md`) — project instructions both agents load at startup.
.pi/extensions/protect-build-artifacts.ts:33
resolve(path)returns platform-native separators (e.g.,\on Windows), but the patterns include forward slashes (e.g.,"/target/"). On Windows this will fail to match and the guard won’t block writes intotarget/. Normalize the resolved path beforeincludesmatching.
const resolved = resolve(path);
const match = PROTECTED.find((p) => resolved.includes(p.pattern));
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
.pi/extensions/protect-build-artifacts.ts:33
resolve(path)returns platform-specific separators (e.g.,C:\...\src\generated\...on Windows). Since the protected patterns use forward slashes ("src/generated","/target/"), the guard won’t match on Windows and edits to generated/build output paths would slip through.
const resolved = resolve(path);
const match = PROTECTED.find((p) => resolved.includes(p.pattern));
shahan-khatchadourian-anchorage
left a comment
There was a problem hiding this comment.
Nice parity work — the two pi hooks are a faithful, not-cargo-culted port of the existing Claude Code equivalents (format-on-edit.ts mirrors .claude/hooks/format-on-edit.sh's semantics exactly: .rs-only, best-effort, never blocks the agent), and the AGENTS.md symlink is the right call over a copy — single source of truth, and .claude-loaded instructions and pi-loaded ones can't drift apart. Also good to see both rounds of Copilot's feedback genuinely landed (verified against the current head blobs, not just the reply threads) — the codegen/ false-positive, the /target/ relative-path miss, and the swallowed-failure notification are all fixed.
I ran the two extension files through pi's own loader (jiti) with synthetic tool_call/tool_result events, and separately installed the real pi CLI against a sample project layout to confirm both files load cleanly under real project-trust + discovery (with a negative control: intentionally broken syntax does surface a loud Failed to load extension error, so the clean load is a meaningful signal, not a silent no-op). Core behavior — POSIX path blocking, the rustfmt reformat, the isError short-circuit, graceful handling of a file rustfmt can't parse — all checked out.
A handful of non-blocking notes for a fast-follow, roughly in order of "worth doing soon" to "whenever":
protect-build-artifacts.ts:32-33—resolved.includes(pattern)on a fully-resolved absolute path can match an ancestor directory, not just a repo-relative one (e.g. a checkout under~/target/visualsign-parserwould get every write blocked repo-wide). Relativizing to repo root before matching would tighten this. Same root cause makes the guard a silent no-op on Windows, sinceresolve()yields\separators against forward-slash patterns.- No
.gitignoreentries were added for.pi/'s own per-user scratch (e.g. whatever/trustpersists) — worth mirroring the.claude/treatment so contributors runningpidon't pick up untracked local state ingit status. "Cargo.lock"as a bare substring also matches nested lockfiles likefuzz/Cargo.lock, with a block reason ("workspace lockfile") that wouldn't be accurate for that one. A basename check would be more precise.- The guard only intercepts
write/edit—bash(e.g.sed -i) bypasses it entirely. The doc comment reads a bit stronger than what's implemented; a one-line caveat would keep the next reader from over-trusting it. - No timeout on the spawned
rustfmtprocess informat-on-edit.ts— a wedged process would stall the turn indefinitely. stdio: "ignore"in the same file discards rustfmt's actual error output, so the warning toast can't say more than "exited 1."- No
try/catcharound the await — the file's contract is "never blocks the agent," and a baretry/catchwould make that airtight against an unexpected synchronous throw. - Minor: the per-user-scratch sentence in the new CONTRIBUTING.md section is stated twice; and
AGENTS.mdinherits aCLAUDE.mdopening line that's Claude-Code-specific phrasing, harmless but slightly odd for api/Cursor reader.
Scanned other open and recently-merged PRs — nothing touches .claude/, .pi/, AGENTS.md, or CONTRIBUTING.md, so no conflicts or rebases needed here.
Addresses the 8 non-blocking notes from shahan's review on PR #433: protect-build-artifacts.ts: - Match paths relative to the project root (ctx.cwd), normalized to forward slashes. Fixes the ancestor-dir false positive (a checkout under ~/target/visualsign-parser no longer trips /target/) and the Windows separator no-op in one shot. - Cargo.lock now blocks the workspace root lockfile only, so a nested fuzz/Cargo.lock is left alone and the 'workspace lockfile' reason stays accurate. target/ matched as a real path segment, not a substring. - Header note that the guard only intercepts write/edit; bash (sed -i) bypasses it, so it is a guardrail, not an airtight sandbox. format-on-edit.ts: - Add a 10s timeout that SIGKILLs a wedged rustfmt instead of stalling the agent turn. - Capture stderr (stdio pipe) and include rustfmt's last stderr line in the warning so failures say more than 'exited 1'. - Wrap the spawn in try/catch so a synchronous throw can never block the agent, matching the file's 'never blocks' contract. .gitignore: - Ignore .pi/mega-compact/ (per-user scratch: sqlite db, events log, dashboard), mirroring the .claude/ treatment. Trust decisions live in the global ~/.pi/agent store, not in the repo. CONTRIBUTING.md: - Dedup the per-user-scratch sentence (it was stated twice); document .pi/mega-compact/ alongside the .claude scratch entries. CLAUDE.md: - Soften the opening line to agent-neutral phrasing now that AGENTS.md symlinks here and both Claude Code and pi load it.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.pi/extensions/protect-build-artifacts.ts:57
toRel()claims that paths outside the repo "can't match" becauserelative()yields a..prefix, but the current matchers (notablyrel.includes("/target/")) will still match strings like../target/...and incorrectly block edits outside the repo. Either update the comment or explicitly treat any..-prefixed relative path as non-matchable.
// Repo-relative, forward-slash path. resolve() handles both absolute and
// relative tool inputs; relative() re-roots them at the project dir so a path
// outside the repo can't match (it yields a `..` prefix).
function toRel(cwd: string, raw: string): string {
const rel = relative(cwd, resolve(cwd, raw));
CLAUDE.md:4
- This file now states it is symlinked as
AGENTS.md, butAGENTS.mddoes not exist in the PR workspace. Either add theAGENTS.md -> CLAUDE.mdsymlink/file as described, or remove the symlink claim here (and in CONTRIBUTING.md) to avoid misleading setup instructions.
# CLAUDE.md
This file provides guidance to coding agents (Claude Code and pi) when working with code in this repository. It is symlinked as `AGENTS.md` so both agents load the same instructions.
Copilot's re-review of 6208606 (suppressed, low-priority) noted that toRel()'s claim 'a path outside the repo can't match' was wrong: relative() yields a '..' prefix, but the '/target/' matcher uses rel.includes('/target/') which still matches '../target/...'. That re-introduced the exact ancestor-dir false positive the repo-relative rewrite was meant to fix (e.g. an edit under ~/target/visualsign-parser, resolved to ../target/..., would be blocked). Now explicitly skip any rel path that starts with '..' before matching, so outside-repo edits are never treated as repo build artifacts.
There was a problem hiding this comment.
Re-reviewed at c032f6d8. All eight of the fast-follow notes landed, and I checked them against the head blobs rather than the reply threads: the repo-relative rewrite kills the ancestor-dir false positive and the Windows separator no-op in one move, target is now a real path segment (my-target/ is correctly left alone), the bash/sed -i caveat is in the header, and format-on-edit.ts picked up the timeout, stderr capture, and the outer try/catch. The .. short-circuit in patch 5 is the right shape too — I confirmed it doesn't over-fire: ../visualsign-parser/src/generated/y.rs still normalizes back inside the repo and blocks, so only genuinely-outside paths get skipped. Nice iteration density for a chore PR, and the PROTECTED table growing a per-entry match predicate reads much better than the substring list it replaced.
All checks green on head (ubuntu, both CodeQL analyses, CLA, labeler).
On the Cargo.lock rule
.pi/extensions/protect-build-artifacts.ts:47 — the Cargo.lock rule no longer matches anything. There is no root Cargo.lock in this repo, so rel === "Cargo.lock" is dead code:
$ git ls-tree -r --name-only HEAD | grep 'Cargo.lock$'
src/Cargo.lock <- the workspace lockfile
src/visualsign/Cargo.lock
src/chain_parsers/visualsign-solana/fuzz/Cargo.lock
tools/tvc-deploy/Cargo.lock
The pre-patch-4 path.includes("Cargo.lock") did block all four (over-broadly); the tightening overshot and now blocks none. Simulating the current matcher against these paths: src/Cargo.lock → allow. My earlier note was aiming at a basename check rather than root-only — sorry if that read as "repo root," the intent was "the reason string should match the file it fires on."
Either of these restores the guard:
// Option A - protect the actual workspace lockfile.
{ reason: "workspace lockfile", match: (rel) => rel === "src/Cargo.lock" },
// Option B - all lockfiles, with a reason that stays true for the nested ones.
{ reason: "a cargo lockfile (regenerate via cargo, don't hand-edit)",
match: (rel) => rel === "Cargo.lock" || rel.endsWith("/Cargo.lock") },I'd lean B, since fuzz/Cargo.lock and src/visualsign/Cargo.lock are equally not-hand-editable, and it keeps the rule from silently rotting if the workspace root ever moves again.
Other notes
-
.gitignoremisses pi's own project-scoped dirs. pi writes project-scope package installs to.pi/npm/and.pi/git/(package-manager.js,getNpmInstallRoot/getGitInstallRoot:scope === "project"→join(cwd, ".pi", "npm" | "git")). Those are machine-specific trees that'd land ingit statusfor anyone who installs a project-scoped pi package. Conversely,.pi/mega-compact/has no reference anywhere in pi core (checked 0.83.0) — it looks like it comes from a globally-installed extension, so as written the shared ignore file encodes one contributor's extension while leaving pi's own dirs uncovered. Adding.pi/npm/and.pi/git/alongside it would make the "mirrors the.claude/treatment" comment true. -
The parity is one-directional on the guard.
.claude/settings.jsonhas only thePostToolUsefmt hook — nopermissionsblock, so Claude Code has no equivalent of the build-artifact guard. CONTRIBUTING's "applies to both Claude Code and pi" is accurate for the instructions and formatting, slightly generous for the guard. Apermissions.denyentry (Write(./src/generated/**),Edit(./src/generated/**)) would close it, or the bullet could say the guard is pi-side today. -
ctx.cwdisn't quite "the project root." It's pi's launch cwd (main.jsprocess.cwd()→AgentSession._cwd→ExtensionRunner). It happens to equal the repo root whenever this extension is live, becausediscoverAndLoadExtensionsonly looks atjoin(cwd, ".pi", "extensions")with no parent walk — launch fromsrc/and the extension isn't loaded at all, so there's no wrong-root window. An explicitly-configured extension path would bypass that and silently stop matchingsrc/generated. Worth a clause in the header noting the invariant comes from cwd-local discovery, so a future reader doesn't have to re-derive why the assumption holds. -
format-on-edit.ts:73— last stderr line is usually the least useful one.stderr.trim().split("\n").pop()on a rustfmt parse error lands on the caret/|continuation line; the first line carries the actual message.split("\n")[0](or first + the-->location) would make the toast say more. -
format-on-edit.ts:31— nocwdon the spawn, while the guard now carefully usesctx.cwd. Relative tool paths only work because the process cwd happens to match; passingcwd: ctx.cwdmakes the two files symmetric for free. -
format-on-edit.ts:63— the timer kills but doesn't settle. In practiceSIGKILLguaranteesclose, so the 10s bound holds; callingfinish()from the timer as well would make it unconditional rather than dependent on the child behaving.
Related in-flight work
- #421 (also yours) touches
CLAUDE.mdin the same Workspace Lint Policy neighbourhood — it inserts a "Deploy-Time ABI Trust Posture" section right after theExceptions:line, a few lines below the blank-line-before-list change here. Close enough that whichever merges second may want a quick rebase check. - #436 adds
src/rustfmt.tomlwithedition = "2024". No conflict, but once it lands the hardcoded--edition 2024in both format hooks is redundant for anything undersrc/. Keeping the flag is still defensible since it also covers.rsfiles outsidesrc/(e.g.tools/) — just flagging so the two don't drift if the edition ever bumps.
Nothing else open or recently merged touches .claude/, .pi/, AGENTS.md, or CONTRIBUTING.md.
shahan-khatchadourian-anchorage
left a comment
There was a problem hiding this comment.
Approving. The Cargo.lock matcher note above is a follow-up rather than something to hold this on — the rest of the guard and the format hook both check out, and the shared-config setup is a clear improvement over the untracked state.
Summary
Brings pi to parity with the existing Claude Code agent setup so both agents share the same project instructions and post-edit formatting, with one source of truth.
Changes
.pi/extensions/—protect-build-artifacts.tswas already present but untracked; now committed so the team shares it. Newformat-on-edit.tsmirrors.claude/hooks/format-on-edit.sh, runningrustfmt --edition 2024after awrite/editon a.rsfile (best-effort: never blocks the agent on a formatting failure; skips when the edit itself errored).AGENTS.md— symlink toCLAUDE.mdso tools that look forAGENTS.md(pi, Cursor, others) find the same instructions Claude Code already loads. Verified in pi's source (resource-loader.js):existsSync/readFileSyncfollow symlinks; candidate order isAGENTS.mdfirst and the per-dir loop returns on first match, soCLAUDE.mdis not double-loaded.CONTRIBUTING.md— new "Agent setup" section documenting the shared, tracked config vs. the per-user scratch that stays gitignored.Why
Both agents already worked here, but pi's project-local config was untracked and pi lacked the format-on-edit hook Claude had. This makes the setup shared and consistent across agents, with instructions living in a single file.
Verification
.rsfilter, `isError` guard, `write`/`edit` only).Not verified: a live interactive `pi` session confirming hot-load + `/trust` prompt + the build-artifact guard blocking a real edit in a TTY (can't be driven headless). Code is proven correct and loadable.
Notes
.claude/settings.local.jsonremains a dangling symlink in this worktree (points to a repo-root file that doesn't exist) — that's per-user, gitignored state, not part of this shared config.protect-build-artifacts.tsuses directevent.input.pathaccess (no?.); confirmed non-optional in pi'sToolResultEventBase(Record<string, unknown>), and the new extension matches that style.