|
| 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