Skip to content

Commit b260e66

Browse files
committed
Add scm-flow extension for pi
1 parent 50eedac commit b260e66

36 files changed

Lines changed: 5927 additions & 0 deletions
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
---
2+
summary: Positive intent→cmd layer over gt/git in Graphite repos. Replaces raw shell strings with structured tools.
3+
commands: [/scm-flow]
4+
tools:
5+
- scm_add
6+
- scm_restore
7+
- scm_branch_create
8+
- scm_branch_checkout
9+
- scm_branch_rename
10+
- scm_branch_delete
11+
- scm_commit
12+
- scm_reset
13+
- scm_revert
14+
- scm_cherry_pick
15+
- scm_stack_log
16+
- scm_stack_track
17+
- scm_stack_restack
18+
- scm_stack_sync
19+
- scm_stack_submit
20+
category: workflow
21+
keywords: [git, graphite, gt, stacking, scm]
22+
status: scaffold
23+
---
24+
25+
# scm-flow
26+
27+
A positive intent→command layer over `gt`/`git` in Graphite-managed repos.
28+
29+
## Why
30+
31+
`prefer-graphite` blocks raw mutating `git` and tells the agent to use `gt`. That's the
32+
**negative** half. Logs from `~/.agents/var/logs/prefer-graphite/` showed the agent:
33+
34+
- defaulted to `git add && git commit` despite the redirect (8 occurrences in 2 weeks)
35+
- obfuscated `git` invocations (`/usr/bin/git`, `g""it`) to bypass interception
36+
- iterated on `gt modify` flags (`-c`, `-a`, `-u`, `-F`, `--no-interactive`) to find the right combo
37+
- hit the **untracked-branch wall** on `gt branch info` / `gt log -s` and required rescue with `gt track -p main`
38+
- broke multi-line commit messages built via `cat /tmp/file && gt modify -m "$(cat …)"` across shell-call boundaries
39+
- couldn't tell `gt submit --no-edit` vs `--no-interactive` vs `--branch` vs `--stack` apart
40+
41+
This extension adds the **positive** half: structured tools the agent calls with typed
42+
arguments. The tool layer absorbs the gt-flag soup, tmpfile lifecycle, and stack-aware
43+
state coordination.
44+
45+
## Mental model
46+
47+
The agent thinks in **git verbs** for ops on commits/branches:
48+
`scm_add`, `scm_restore`, `scm_branch_create`, `scm_branch_checkout`, `scm_commit`,
49+
`scm_reset`, `scm_revert`, etc.
50+
51+
The agent uses **`scm_stack_*`** for graphite-native stack ops with no clean git equivalent:
52+
`scm_stack_log`, `scm_stack_track`, `scm_stack_restack`, `scm_stack_sync`, `scm_stack_submit`.
53+
54+
## Components
55+
56+
| File | Purpose |
57+
|---|---|
58+
| `index.ts` | Wires hooks, commands, and tool registration |
59+
| `detect.ts` | `isGraphiteRepo()` — walk-up + git-common-dir worktree detection, per-dir cache |
60+
| `parse.ts` | Quote-aware command parsing: `splitSubCommands`, `stripQuotedContent`, `stripHeredocBodies`, `isGitCommand`, `isShellDispatcher` |
61+
| `interceptor.ts` | `pi.on("tool_call")` hook: blocks raw mutating git with tool hints; passes through read-only ops; detects obfuscation |
62+
| `system-prompt.ts` | `buildSystemPromptAddition()` — injected via `before_agent_start` when in a graphite repo |
63+
| `tools/_helpers.ts` | Shared exec primitives, tmpfile lifecycle, stack-state detection |
64+
| `tools/_schemas.ts` | Shared TypeBox primitive schemas |
65+
| `tools/<verb>.ts` | One file per `scm_*` tool |
66+
67+
## Tool → shell-op map
68+
69+
### Git-aligned (commits / branches)
70+
71+
| Tool | Shell op |
72+
|---|---|
73+
| `scm_add` | `git add [-N] <paths…>` |
74+
| `scm_restore` | staged: `git restore --staged <paths>` · worktree: `git restore [--source <ref>] <paths>` |
75+
| `scm_branch_create` | `git checkout -b <name> && gt track --parent <parent ?? current>` |
76+
| `scm_branch_checkout` | optional `gt get <name>`; then `gt co <name>` |
77+
| `scm_branch_rename` | `gt rename <new_name>` |
78+
| `scm_branch_delete` | `gt delete <name> -q [-f] [--upstack] [--downstack] [-c]` |
79+
| `scm_commit` | new: `gt modify -c [-u] -F <tmpfile> --no-interactive` · amend: `gt modify [-u] [-F tmpfile \| --no-edit] --no-interactive` |
80+
| `scm_reset` | `git reset --<mode> <target>`; if children & `auto_restack`: `gt restack` |
81+
| `scm_revert` | `git revert [-m N] <shas…>`; if children & `auto_restack`: `gt restack` |
82+
| `scm_cherry_pick` | `git cherry-pick <shas…>`; if children & `auto_restack`: `gt restack` |
83+
84+
### gt-native (stack-as-graph)
85+
86+
| Tool | Shell op |
87+
|---|---|
88+
| `scm_stack_log` | reads `.graphite_cache_persist` + `.graphite_pr_info` → recursive tree + orphans |
89+
| `scm_stack_track` | `gt track --parent <parent>` |
90+
| `scm_stack_restack` | `gt restack` (defers to `restack-workflow` on conflicts when detected via `pi.getAllTools()`) |
91+
| `scm_stack_sync` | `gt sync --no-interactive` + before/after cache diff (trunk advance, pruned, newly-valid) |
92+
| `scm_stack_submit` | `gt submit --no-interactive […]` + before/after `.graphite_pr_info` diff (per-branch action: created/updated/unchanged/failed) |
93+
94+
## Locked design defaults
95+
96+
- `scm_branch_create({allow_dirty: false})` — default false, errors on dirty worktree; `allow_dirty: true` carries changes over (matches git's default `checkout -b` semantics)
97+
- `scm_branch_checkout({fetch_if_missing: true})` — default true, auto-runs `gt get <name>` if branch missing locally
98+
- `scm_branch_checkout` target-untracked-in-graphite: **warn-return** (succeed, surface `tracked: false` in result)
99+
- `scm_commit({amend: true})` on already-pushed commits: **succeed** (matches git semantics; force-push handled later via `scm_stack_submit`)
100+
- `scm_stack_log` returns `{trunks: StackNode[], orphaned: StackNode[]}` — trunks recursive, orphans flat. Orphans surface branches in the graphite cache with `BAD_PARENT_NAME`/`INVALID_PARENT` (typically created via raw `git checkout -b` without `gt track`) so the agent sees them too; their `needs_restack` is always true.
101+
- `scm_commit` auto-appends `Co-authored-by: AI (Pi/<model-id>) <noreply@pi.dev>` to commit body unless `co_author: false`. Model id is read from `ctx.model.id` at execute time (eg `claude-sonnet-4-5`); falls back to `AI (Pi)` when unknown.
102+
- `scm_reset` / `scm_revert` default `auto_restack: true` — fires `gt restack` after if descendants exist; surfaces conflicts in result without resolving
103+
- `scm_stack_restack` runs `gt restack`; on conflicts, checks `pi.getAllTools()` for `restack_run` and emits an enhanced hint pointing at `restack-workflow`'s context-aware flow if it's loaded
104+
- `scm_stack_submit` / `scm_stack_sync` parse graphite's JSON cache (`.graphite_cache_persist`, `.graphite_pr_info`) before & after invocation — more robust than parsing the CLI's emoji-decorated stdout, which varies between gt releases
105+
106+
## Commands
107+
108+
| Command | Effect |
109+
|---|---|
110+
| `/scm-flow` | Toggle interceptor + tool registration on/off |
111+
| `/scm-flow on` | Force enable |
112+
| `/scm-flow off` | Force disable (raw git allowed; tools still registered but unused) |
113+
| `/scm-flow status` | Show toggle state + detected graphite repo root + system-prompt injection state |
114+
115+
## Detection
116+
117+
Auto-detects graphite repos via `isGraphiteRepo(cwd)`:
118+
119+
1. Walks up from `cwd` looking for `.graphite/` directory
120+
2. Falls back to `git rev-parse --git-common-dir` + check for `.graphite_repo_config` (worktree layout, eg `~/world`)
121+
122+
Results cached per resolved-cwd for the session.
123+
124+
### Scope: `ctx.cwd`, not bash-effective cwd
125+
126+
The interceptor evaluates `isGraphiteRepo(ctx.cwd)` where `ctx.cwd` is **pi's
127+
session cwd at the time the hook fires** — i.e. the directory pi was launched
128+
in (or last `/cd`'d to). It is NOT the bash command's effective cwd after any
129+
internal `cd <path> && …`.
130+
131+
Consequence: every git command in a pi session inherits the graphite-or-not
132+
verdict of the launch directory. If pi was launched in `~/world/trees/root/src`
133+
(graphite), a bash call like `cd ~/nixfiles && git revert --abort` will still
134+
be intercepted as if it ran in `~/world`. The block message is correct from
135+
pi's project-context perspective, but may look surprising at the bash level.
136+
137+
This matches `prefer-graphite`'s behavior. If you genuinely need to escape the
138+
interception for a one-off cross-repo command, run `/scm-flow off`, do the work,
139+
and re-enable with `/scm-flow on`.
140+
141+
## Interception (carried from `prefer-graphite`, extended)
142+
143+
### Passthrough (read-only + safe-no-stack-impact)
144+
145+
`git status`, `git diff`, `git log`, `git show`, `git blame`, `git grep`, `git rev-parse`,
146+
`git describe`, `git ls-files`, `git ls-tree`, `git cat-file`, `git for-each-ref`,
147+
`git stash`, `git fetch`, `git worktree`, `git mv`, `git rm`, `git tag`, `git notes`,
148+
`git clean`, plus heuristic-classified pathspec-form `git reset <path>` and
149+
`git restore <path>` (no `HEAD`/`@`/`^`/`~`/`--soft`/`--mixed`/`--hard`/`--staged`/sha-ish args).
150+
151+
### Redirect (mutating git → tool hint)
152+
153+
| Raw | → Tool |
154+
|---|---|
155+
| `git add` | `scm_add` |
156+
| `git restore --staged …` / `git reset --staged …` | `scm_restore({staged: true})` |
157+
| `git checkout <branch>` / `git switch <branch>` | `scm_branch_checkout` |
158+
| `git checkout -b` / `git switch -c` | `scm_branch_create` |
159+
| `git checkout -B` | hard block — use `scm_branch_delete` + `scm_branch_create` |
160+
| `git branch -m` | `scm_branch_rename` |
161+
| `git branch -d/-D` | `scm_branch_delete` *(v1.1)* |
162+
| `git commit` | `scm_commit` |
163+
| `git commit --amend` | `scm_commit({amend: true})` |
164+
| `git push` | `scm_stack_submit` |
165+
| `git pull` | `scm_stack_sync` |
166+
| `git rebase` | `scm_stack_restack` |
167+
| `git reset HEAD~/sha/--hard …` | `scm_reset` |
168+
| `git revert` | `scm_revert` |
169+
| `git cherry-pick` | `scm_cherry_pick` *(v1.1)* |
170+
| `git merge` | `gt fold` (no tool — rare) |
171+
172+
### Obfuscation block
173+
174+
Hard-blocks: `/usr/bin/git`, `g""it`, `g\"\"it`, `\git`, `command git`.
175+
176+
### Quoted arguments
177+
178+
Text inside single- or double-quoted arguments is stripped before matching (prevents
179+
false allow/block from commit-message body or grep patterns). Shell dispatchers
180+
(`bash -c "…"`, `eval "…"`) are special-cased — their quoted arg IS the command.
181+
182+
## System prompt injection
183+
184+
When in a graphite repo (via `isGraphiteRepo(cwd)`), prepends a tool-mapping summary to
185+
the system prompt via `before_agent_start`. Inert in non-graphite repos.
186+
187+
See `system-prompt.ts` for content.
188+
189+
## Tests
190+
191+
Tests use [vitest](https://vitest.dev) (same as `prefer-graphite`). They live
192+
alongside the modules they cover:
193+
194+
| File | Tests | Style |
195+
|---|---|---|
196+
| `parse.test.ts` | 49 | pure-fn |
197+
| `interceptor.test.ts` | 45 | pure-fn |
198+
| `tools/_helpers.test.ts` | 17 | pure-fn |
199+
| `tools/stack_log.test.ts` | 21 | pure-fn |
200+
| `tools/stack_submit.test.ts` | 8 | pure-fn |
201+
| `tools/stack_sync.test.ts` | 7 | pure-fn |
202+
| `tools/commit.test.ts` | 13 | `vi.mock`'d |
203+
| `tools/branch_create.test.ts` | 15 | `vi.mock`'d |
204+
| `tools/branch_delete.test.ts` | 13 | `vi.mock`'d |
205+
| `tools/cherry_pick.test.ts` | 12 | `vi.mock`'d |
206+
207+
### Running via vitest
208+
209+
Vitest isn't shipped in the deployed pi binary; tests run in the pi monorepo's
210+
dev environment, where `npx vitest` (or `pnpm vitest`) is available.
211+
212+
From inside a pi monorepo dev checkout:
213+
214+
```bash
215+
# Symlink (or copy) this extension into the monorepo's extensions/ dir, then:
216+
npx vitest run extensions/scm-flow/
217+
218+
# Or target a single file:
219+
npx vitest run extensions/scm-flow/interceptor.test.ts
220+
221+
# Watch mode:
222+
npx vitest extensions/scm-flow/
223+
```
224+
225+
This mirrors the `prefer-graphite` convention (see the comment block at the top
226+
of each test file).
227+
228+
### Standalone smoke run (no vitest required)
229+
230+
The pure-fn tests (`parse.test.ts`, `interceptor.test.ts`,
231+
`tools/_helpers.test.ts`) can be executed without vitest using a minimal stub:
232+
233+
```bash
234+
cd /tmp && rm -rf scm-flow-smoke && mkdir scm-flow-smoke && cd scm-flow-smoke
235+
ln -sfn ~/nixfiles/lib/pi/agent/extensions/scm-flow src
236+
237+
# Write a 30-line vitest stub at node_modules/vitest/{index.js,index.d.ts}
238+
# (describe/it/expect/beforeEach → node:assert; ignores vi.mock).
239+
# See the inline harness used during development.
240+
241+
# Compile + run:
242+
npx tsc -p tsconfig.compile-tests.json
243+
node run-vitest-stub.mjs ./dist-tests/parse.test.js \
244+
./dist-tests/interceptor.test.js \
245+
./dist-tests/tools/_helpers.test.js
246+
```
247+
248+
The `vi.mock`-based tests (`commit.test.ts`, `branch_create.test.ts`) cannot run
249+
under the stub — they need real vitest's module-mocking infrastructure.
250+
251+
### Typecheck only
252+
253+
```bash
254+
npx tsc -p . # any tsconfig with strict + nodeNext resolution
255+
```
256+
257+
A reference tsconfig that satisfies the imports (assuming `pi-coding-agent`
258+
0.74.x and `typebox` 1.1.x are wired into `node_modules/`):
259+
260+
```json
261+
{
262+
"compilerOptions": {
263+
"target": "ES2022",
264+
"module": "NodeNext",
265+
"moduleResolution": "NodeNext",
266+
"strict": true,
267+
"noEmit": true,
268+
"esModuleInterop": true,
269+
"skipLibCheck": true
270+
},
271+
"include": ["**/*.ts"]
272+
}
273+
```
274+
275+
### What to add when changing the extension
276+
277+
| Change | Add a test in |
278+
|---|---|
279+
| Parse primitive (split, strip, dispatcher, obfuscation) | `parse.test.ts` |
280+
| Interceptor passthrough or redirect | `interceptor.test.ts` |
281+
| Tool result-shape or message-composition helper | `tools/_helpers.test.ts` |
282+
| New `scm_*` tool | new `tools/<tool>.test.ts` mirroring `commit.test.ts` (mocks `_helpers.js` via `vi.mock`, captures the registered tool via a fake `pi.registerTool`, asserts shell-op args + result shape) |
283+
284+
## Status
285+
286+
**v1.1 complete.** All 15 tools functional. Plumbing carries `prefer-graphite`'s
287+
tested helpers verbatim. Stack-* parsers read graphite's JSON cache rather than
288+
`gt` text output for stability.
289+
290+
**Test coverage**: 154 standalone (pure-fn) tests pass. Mock-based tool tests
291+
(`commit`, `branch_create`, `branch_delete`, `cherry_pick`) add 53 more for
292+
guardrail and exec-shape verification — require real vitest to run.
293+
294+
## Future (v1.2+)
295+
296+
- `renderResult` collapsible TUI rendering (mirror `restack-workflow`'s
297+
`renderExpandableToolResult` — needs `pi-tui` import that works at runtime
298+
via pi's bundler but lacks local types; defer until needed)
299+
- Mock-based tests for the remaining tool execute() paths (currently:
300+
`commit`, `branch_create`, `branch_delete`, `cherry_pick`; missing:
301+
`branch_checkout`, `restore`, `reset`, `revert`, `stack_log`/`submit`/`sync`
302+
execute paths, `stack_restack`, `stack_track`, `branch_rename`, `add`)
303+
- Live integration round in a worktree — try `scm_stack_submit` and
304+
`scm_stack_sync` against an actual stack to validate the cache-diff approach

0 commit comments

Comments
 (0)