From 3e49e3680641f9400f02fa8dfcd7b305e5a29f92 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 12 Jul 2026 19:24:21 +0200 Subject: [PATCH] docs(plans): implementation-ready plans for roadmap Wave 1-4 features (8, parallel-generated) --- docs/plans/w1-approval-preview.md | 155 ++++++++++++++++++++++++ docs/plans/w1-clickable-links.md | 143 ++++++++++++++++++++++ docs/plans/w2-pty-inject-queue.md | 138 +++++++++++++++++++++ docs/plans/w3-diff-vs-base.md | 131 ++++++++++++++++++++ docs/plans/w3-pr-ci-chip.md | 181 ++++++++++++++++++++++++++++ docs/plans/w3-quick-wins.md | 174 ++++++++++++++++++++++++++ docs/plans/w4-commit-push.md | 135 +++++++++++++++++++++ docs/plans/w4-worktree-lifecycle.md | 147 ++++++++++++++++++++++ 8 files changed, 1204 insertions(+) create mode 100644 docs/plans/w1-approval-preview.md create mode 100644 docs/plans/w1-clickable-links.md create mode 100644 docs/plans/w2-pty-inject-queue.md create mode 100644 docs/plans/w3-diff-vs-base.md create mode 100644 docs/plans/w3-pr-ci-chip.md create mode 100644 docs/plans/w3-quick-wins.md create mode 100644 docs/plans/w4-commit-push.md create mode 100644 docs/plans/w4-worktree-lifecycle.md diff --git a/docs/plans/w1-approval-preview.md b/docs/plans/w1-approval-preview.md new file mode 100644 index 0000000..7c33193 --- /dev/null +++ b/docs/plans/w1-approval-preview.md @@ -0,0 +1,155 @@ +# Approval preview — command/diff on the approval bar + +## Summary & grounding + +Today `/hook/permission` (`src/server.ts:423`) reads the hook body, extracts only `tool_name` (`server.ts:430`), derives a `gate` (`server.ts:454`), parks the held `res` in `pendingApprovals` (`server.ts:464`), and broadcasts a bare `waiting` status via `manager.handleHookEvent(sessionId, 'waiting', tool, true, gate)` (`server.ts:471`). The rich `tool_input` — which `parseHookEvent` already knows how to pass through verbatim (`src/http/hook.ts:104-105`) — is dropped on this route. The status `ServerMessage` (`src/types.ts:113-119`) has no field to carry a preview, so the approval bar in `public/tabs.ts:360-377` can only say *"Claude wants to use Bash"*. + +This feature derives a **bounded, sanitized preview** from `tool_input` server-side, threads it through the same broadcast + late-joiner re-send paths the `gate` already uses, and renders it above the Approve/Reject buttons — reusing the render-only diff renderer (`public/diff.ts:139` `renderDiffFile`) and the `sanitizeField` pattern (`src/session/timeline.ts:50`). + +Design decision: the derive logic lives in a **new pure module** `src/http/approval-preview.ts` (not inlined in `hook.ts`) so it is unit-testable in isolation and keeps `hook.ts` focused; `hook.ts` is cited only as the proof that `tool_input` is already available on the hook body. The `/hook/permission` route calls it directly (it does not go through `parseHookEvent`). + +--- + +## Contract + +### New message field — `src/types.ts` (coordination edit) + +Add a new exported type and extend the `status` variant of `ServerMessage` (currently `src/types.ts:113-119`). Additive + optional → older clients ignore it; the frontend exhaustiveness check (`terminal-session.ts:349-355`) is unaffected. + +```ts +/** A5-adjacent: compact, BOUNDED preview of what a held approval will run. + * Derived server-side from the hook tool_input, sanitized + byte-capped. + * Discriminated on `kind`: + * - 'command' → a shell command string (Bash). Newlines PRESERVED; all other + * control/ANSI chars stripped. Rendered in a
 via textContent.
+ *   - 'diff'    → ONE synthetic DiffFile (Edit/Write/MultiEdit/NotebookEdit),
+ *     rendered by public/diff.ts renderDiffFile (textContent-only, SEC-H4).
+ *  `truncated` = the source exceeded the line/byte cap and was clipped. */
+export type ApprovalPreview =
+  | { kind: 'command'; text: string; truncated?: boolean }
+  | { kind: 'diff'; file: DiffFile; truncated?: boolean };
+```
+
+Extend the status variant:
+
+```ts
+  | {
+      type: 'status';
+      status: ClaudeStatus;
+      detail?: string;
+      pending?: boolean;
+      gate?: PermissionGate;
+      preview?: ApprovalPreview;   // NEW — present only on a held (pending) waiting status
+    }
+```
+
+Extend `SessionManager.handleHookEvent` (currently `src/types.ts:331-339`) with a trailing optional param (appended last so the existing positional `/hook` call at `server.ts:412` is untouched):
+
+```ts
+  handleHookEvent(
+    sessionId: string,
+    status: ClaudeStatus,
+    detail?: string,
+    pending?: boolean,
+    gate?: PermissionGate,
+    eventClass?: string,
+    toolName?: string,
+    preview?: ApprovalPreview,   // NEW
+  ): void;
+```
+
+### New route behavior
+
+No new route. `POST /hook/permission` (`server.ts:423`) gains: derive a preview from `body['tool_input']` + `tool`, store it on the `PendingApproval`, pass it into `handleHookEvent`. `GET`/other routes unchanged.
+
+### New env vars — `src/config.ts`
+
+**None.** The bounds are security limits, not user knobs (loosening them is a DoS/broadcast-bloat vector), so they are module constants in `approval-preview.ts`, not config. (Documented as a deliberate choice; if a knob is later wanted, `APPROVAL_PREVIEW_BYTES` slots into `Config` next to the existing `previewBytes` field.)
+
+### Bounds (module constants in `src/http/approval-preview.ts`)
+
+| Constant | Value | Purpose |
+|---|---|---|
+| `PREVIEW_MAX_LINES` | 40 | max diff/command lines emitted |
+| `PREVIEW_MAX_LINE_LEN` | 200 | per-line char cap (= `sanitizeField` default) |
+| `PREVIEW_MAX_BYTES` | 4096 | hard total-byte cap on the serialized preview payload |
+| `EDIT_TOOLS` | `Edit`,`Write`,`MultiEdit`,`NotebookEdit` | reuse the set semantics from `timeline.ts:17` |
+
+---
+
+## Files to change
+
+| Path | Change |
+|---|---|
+| `src/types.ts` | **Coordination edit.** Add `ApprovalPreview` type; add `preview?` to the `status` `ServerMessage` variant (`:113-119`); add trailing `preview?` param to `SessionManager.handleHookEvent` (`:331-339`). |
+| `src/http/approval-preview.ts` | **New file (pure, no DOM, never throws).** `deriveApprovalPreview(toolName: string \| undefined, toolInput: unknown): ApprovalPreview \| null`. Bash→`{kind:'command'}`; Edit/Write/MultiEdit/NotebookEdit→`{kind:'diff', file}`; unknown tool / malformed input→`null`. Imports `sanitizeField` from `../session/timeline.js`; defines a `sanitizeLine` wrapper and per-line splitting so `\n`/`\t` survive but ANSI/control chars don't. |
+| `src/server.ts` | In `/hook/permission` (`:423`): after computing `tool` (`:430`), call `deriveApprovalPreview(tool, body['tool_input'])`. Add `preview?: ApprovalPreview` to the `PendingApproval` interface (`:225-231`); store it at creation (`:464`). Pass `preview` into `handleHookEvent(...)` (`:471`). In the late-joiner re-send (`:858-864`) include `preview: heldApproval.preview`. Import `deriveApprovalPreview` + `ApprovalPreview`. |
+| `src/session/manager.ts` | `handleHookEvent` (`:223-249`): accept trailing `preview?` param; set `msg.preview = preview` when defined, before `broadcast(session, msg)` (`:245-249`). (The Case-2 late-join re-send at `:142` stays bare — the server layer owns pending/preview re-send, per the comment at `:137-138`.) |
+| `public/terminal-session.ts` | Add `private pendingPreviewValue: ApprovalPreview \| null = null` near `:98-106`; getter `get pendingPreview()` near `:185-207`; in `case 'status'` (`:313-322`) set `this.pendingPreviewValue = nextPending ? (msg.preview ?? null) : null`; clear it in the two disconnect resets near `:385-389`. Import `ApprovalPreview` from `../src/types.js`. |
+| `public/tabs.ts` | In `updateApprovalBar` (`:360-377`): after the `label`, if `session.pendingPreview` is set, build and insert a preview node **before** the buttons. Add `private renderApprovalPreview(p: ApprovalPreview): HTMLElement` — `kind:'command'`→`
` via `textContent`; `kind:'diff'`→`renderDiffFile(p.file)` wrapped in a scroll container; append a "… truncated" note when `p.truncated`. Import `renderDiffFile` from `./diff.js` and `ApprovalPreview` from `../src/types.js`. |
+| `public/style.css` | Add `.approval-preview` (scroll container: `max-height`, `overflow:auto`, `overflow-x:auto`), `.approval-cmd` (`white-space:pre-wrap`, monospace), `.approval-truncated` under the existing `#approvalbar` block (`:1084`). Reuse existing `.df-*` styling for the diff. |
+| `test/http/approval-preview.test.ts` | **New (node).** Unit tests for `deriveApprovalPreview`. |
+| `test/manager.test.ts` | Extend: `handleHookEvent` with a `preview` arg puts it on the broadcast status msg. |
+| `test/terminal-session.test.ts` | Extend (jsdom): status frame with `preview` sets `pendingPreview`; cleared when `pending` false / on disconnect. |
+| `test/tabs.test.ts` | Extend (jsdom): `FakeTerminalSession` gains `pendingPreview`; `updateApprovalBar` renders command / diff / truncated note; no-preview → name-only bar (regression). |
+| `test/integration/server.test.ts` | Extend: `POST /hook/permission` with `tool_input` → broadcast `waiting` status carries `preview`; a late-joining WS gets the preview on attach. |
+
+---
+
+## TDD steps (ordered)
+
+Repo style: pure/back-end tests are plain vitest (node) — see `test/hook.test.ts` (`import { describe, it, expect }`, `expect.objectContaining`). Front-end tests carry `// @vitest-environment jsdom` and mock `TerminalSession` (`test/tabs.test.ts`) or mock `WebSocket` + stub xterm (`test/terminal-session.test.ts`). Diff render tests run in jsdom asserting `textContent` (`test/diff.test.ts`).
+
+1. **RED — `test/http/approval-preview.test.ts` (node).** Write, before any impl:
+   - Bash: `deriveApprovalPreview('Bash', { command: 'ls -la', description: 'x' })` → `{ kind:'command', text:'ls -la' }`.
+   - Bash multi-line: `command:'a\nb'` → `text` still contains the `\n` (newlines preserved).
+   - Bash with ANSI/control injection: `command:'\x1b[31mrm\x1b[0m\x07'` → ESC/BEL stripped, no `\x1b`/`\x07` in `text`.
+   - Edit: `{ file_path:'/p/f.ts', old_string:'a\nb', new_string:'c' }` → `{ kind:'diff', file }` where `file.newPath` sanitized, hunk has `removed` lines `a`,`b` and `added` line `c`, `file.removed===2`, `file.added===1`.
+   - Write: `{ file_path, content:'x\ny' }` → all-added hunk, `removed===0`.
+   - MultiEdit: `{ file_path, edits:[{old_string,new_string},{...}] }` → one hunk per edit.
+   - Truncation: `content` with >`PREVIEW_MAX_LINES` lines → `truncated:true`, ≤ cap lines emitted; a >`PREVIEW_MAX_BYTES` blob → `truncated:true` and serialized size ≤ cap.
+   - Unknown tool (`'WebFetch'`) → `null`; missing/`null`/array/number `toolInput` → `null` and **never throws** (mirrors `hook.ts` SEC-M7 style).
+2. **GREEN — implement `src/http/approval-preview.ts`.** Pure, `unknown`-narrowed, `sanitizeField`-per-line, byte-clamped. Run the suite to green.
+3. **RED — `test/manager.test.ts`.** Add: calling `handleHookEvent(id,'waiting','Bash',true,'tool',undefined,undefined,{kind:'command',text:'ls'})` broadcasts a status msg whose `preview` deep-equals the arg (assert via the fake WS `send` capture already used in this file). Add: omitting `preview` → no `preview` key on the msg.
+4. **GREEN — `src/types.ts` param + `src/session/manager.ts`.** Add the trailing param and `msg.preview` assignment.
+5. **RED — `test/integration/server.test.ts`.** Add a test (follow the `itPty` pattern of ⑧ at `:608-655`): attach a WS, `POST /hook/permission` with `{ tool_name:'Bash', tool_input:{ command:'echo hi' } }`, assert the broadcast `pending===true` status has `preview.kind==='command'` and `preview.text` containing `echo hi`. Add a **late-joiner** assertion: open a 2nd WS to the same `sessionId` while held → its first `waiting/pending` status includes `preview`.
+6. **GREEN — `src/server.ts`.** Wire `deriveApprovalPreview` into `/hook/permission`, store on `PendingApproval`, pass to `handleHookEvent`, include in the `:858-864` re-send.
+7. **RED — `test/terminal-session.test.ts` (jsdom).** Feed a `status` frame with `pending:true, gate:'tool', preview:{kind:'command',text:'ls'}` → `session.pendingPreview` set; a follow-up `status` with `pending:false` clears it to `null`; disconnect clears it.
+8. **GREEN — `public/terminal-session.ts`.** Add field, getter, set/clear.
+9. **RED — `test/tabs.test.ts` (jsdom).** Extend `FakeTerminalSession` with `pendingPreview`. Assert `updateApprovalBar`: command preview → a `.approval-cmd` node whose `textContent` equals the command; diff preview → a `.df-file` present (renderDiffFile output); `truncated:true` → `.approval-truncated` present; `pendingPreview:null` → bar shows label + buttons only (regression, matches `:373-374`). Assert **zero `innerHTML`** — content via `textContent` only.
+10. **GREEN — `public/tabs.ts` + `public/style.css`.** Add `renderApprovalPreview`, insert before buttons, style the container.
+11. **Coverage gate.** The new pure module is branch-dense and fully unit-covered (helps the 80% gate); FE branches covered by jsdom tests. Run full `npm test` + coverage; confirm no regression in `hook.test.ts`/`diff.test.ts`/`manager.test.ts`.
+
+---
+
+## Edge cases & failure modes
+
+- **Unknown / non-preview tool** (WebSearch, Task, MCP tools, `ExitPlanMode`): `deriveApprovalPreview` returns `null` → status carries no `preview` → `updateApprovalBar` falls back to today's name-only bar (`:373`). Plan-gate (`gate:'plan'`, `:369-371`) also gets no preview (ExitPlanMode has no reviewable command/diff) — unchanged.
+- **Missing/partial `tool_input`**: Bash without `command`, Edit without `old_string`, `tool_input` present but `null` (the `'tool_input' in b` passthrough at `hook.ts:104` can yield `null`) → return `null`, never throw.
+- **Huge command / whole-file Write**: clipped at `PREVIEW_MAX_LINES` then `PREVIEW_MAX_BYTES`; `truncated:true` shows the "… truncated" note. Prevents a multi-MB status frame from being broadcast to every client and retained in `pendingApprovals`.
+- **Newlines vs control chars**: `sanitizeField` (`timeline.ts:50`) strips `\x00-\x1f` which includes `\n`/`\t`/`\r` — so it is applied **per line after splitting**, never to the whole multi-line blob, preserving structure while still killing ANSI ESC (`\x1b`) and BEL.
+- **MultiEdit with many edits**: hunks accumulate until the line/byte cap, then stop + `truncated:true`.
+- **Late joiner after approval already resolved**: `pendingApprovals.get()` returns `undefined` (`server.ts:858`) → no preview re-sent (correct; nothing is held).
+- **Approve/reject clears preview**: `handleHookEvent(...,'working',...,false)` (`server.ts:887,890`) broadcasts a non-pending status → `terminal-session` sets `pendingPreview = null` → bar hides (existing `:362` guard).
+- **Multi-device**: preview broadcasts to all clients via `broadcast` and re-sends to each new attach — every mirror shows the same preview.
+- **Binary/odd content in Write**: rendered as literal text via `textContent` (no interpretation), `binary:false` on the synthetic file (we don't attempt binary detection — out of scope).
+
+---
+
+## Security
+
+- **Trust boundary**: `tool_input` is Claude-controlled content arriving on the loopback-only `/hook/permission` route (`isLoopback` guard at `server.ts:424`). It is untrusted for *content*: treat as `unknown`, narrow every field (`typeof x === 'string'`), never index without a guard. `deriveApprovalPreview` must **never throw** (SEC-M7 discipline, mirroring `parseHookEvent`).
+- **Sanitization (SEC-H6 reuse)**: every emitted string passes through `sanitizeField`/`sanitizeLine` — strips `\x00-\x1f` (incl. ANSI ESC `\x1b`), truncates to `PREVIEW_MAX_LINE_LEN`. This neutralizes terminal-escape / cursor-hijack payloads in a filename or command before they reach the DOM.
+- **XSS (SEC-H4 reuse)**: the frontend renders **only** via `textContent` / `el()` / `renderDiffFile` (which is already innerHTML-free — `diff.ts:9`, `:191-194`). No `innerHTML` anywhere in the new FE code; asserted in tests. `` survives verbatim in `PrStatus.title` (proves no parsing-side mangling; FE renders it inert).
+3. `classifyGhFailure` — given synthetic exec results:
+   - spawn ENOENT (`code:'ENOENT'`) → `'not-installed'`.
+   - stderr containing `gh auth login` / `not logged` / `authentication` / `HTTP 401` → `'unauthenticated'`.
+   - stderr containing `no pull requests found` / `no default remote` / `no git remote` → `'no-pr'`.
+   - other non-zero exit → `'error'`. Implement `classifyGhFailure` (regex on lower-cased stderr).
+4. `getPrStatus` cache/dedupe — inject a fake runner (or spy) so no real gh spawns:
+   - `ghEnabled:false` in cfg → resolves `{availability:'disabled'}` **without** invoking the runner.
+   - Two rapid calls for the same path share one in-flight run (assert runner called once); after `_clearPrCache()`, it runs again. Mirror `projects.ts:289-311`. Implement the module cache + `_clearPrCache`.
+   - Cache key includes the current branch (cheap read of `.git/HEAD` like `readBranch`, `projects.ts:88`) so a branch switch busts the cache before TTL. Test: same path, different HEAD branch → runner re-invoked.
+
+> To keep `getPrStatus` unit-testable without gh, factor the spawn into an injectable `runGh` (default real, overridable in tests) — same seam idea as `getDiff`'s `runGit`. `parsePrView`/`summarizeChecks`/`classifyGhFailure` stay pure and exported.
+
+**Route — `test/integration/pr-status.test.ts`** (node; mirror `test/integration/projects-endpoint.test.ts:1-55`). Use `getFreePort` + a temp dir with a fake `.git` repo (reuse `makeFakeGitRepo` shape). Stub gh with a **PATH shim**: write an executable script named `gh` into a temp `bin/` that echoes canned JSON (or exits 1 with a canned stderr), then set `process.env.PATH = binDir + ':' + process.env.PATH` before `startServer` (execFile resolves `gh` via PATH). Restore PATH + `_clearPrCache()` in `afterEach`.
+
+5. Missing `path` → **400**. Implement route guard 1.
+6. Non-git dir path → **404** (`isValidGitDir` fails). Implement guard 2.
+7. gh shim emits valid PR JSON → **200** with `availability:'ok'`, correct `checks`. Wire `res.json(await getPrStatus(...))`.
+8. gh shim exits 1 with `no pull requests found` on stderr → **200 `{availability:'no-pr'}`**.
+9. `GH_ENABLED='0'` env → **200 `{availability:'disabled'}`**, and (assert via a shim that writes a marker file) gh is never spawned.
+
+**Frontend — `test/gh-chip.test.ts`** (jsdom; mirror `test/diff.test.ts:1-14`, `// @vitest-environment jsdom`, dynamic `await import('../public/gh-chip.js')`, `vi.stubGlobal('fetch', …)`).
+
+10. `normalizePrStatus` — valid object round-trips; non-object / bad `availability` → `null` (mirror `normalizeDiffResult`, `diff.ts:38`). Implement.
+11. `chipText(status)` — pure map: `ok`+open → `"PR #12 ✓ 5/5"`; failing checks → `"PR #12 ✕ 3/5"`; `mergeable:'conflicting'` adds a `⚠ conflicts` marker/class; `no-pr` → `"No PR"`; `not-installed` → `"gh not installed"` + `title` link to cli.github.com; `unauthenticated` → `"gh auth login"`; `disabled` → chip hidden (returns null/`display:none`). Implement.
+12. `renderPrChip` — **SEC-H4 assert**: a `title` of `` appears as literal text (`el.textContent` contains it, `el.querySelector('img')` is null). Zero `innerHTML`.
+13. `mountPrChip` — `fetch` stubbed to resolve `ok` JSON → placeholder replaced by the chip; `fetch` rejects → degrades to an `error` chip (no throw); `destroy()` removes the node. Mirror `mountDiffViewer` (`diff.ts:253-`). 
+
+**Frontend wiring — extend `test/projects.test.ts`** (jsdom; `renderProjectDetail` is already exported/tested there).
+
+14. `renderProjectDetail` with `detail.isGit:true` → a `.proj-pr-chip` host is present in the header; with `isGit:false` → absent. (Mock `gh-chip`'s `mountPrChip` via `vi.mock` so the DOM assertion doesn't depend on fetch.) Implement the `renderProjectDetail` edit + `prRef.destroy()` in the back handler.
+
+Run `npm test` after each GREEN. The pure backend parser tests (steps 1-3) carry most of the coverage weight for `gh.ts`; FE steps 10-13 cover `gh-chip.ts`.
+
+---
+
+## Edge cases & failure modes
+
+- **`gh` not installed** → spawn ENOENT → `'not-installed'`. Chip shows "gh not installed" (never a 500, never a stack trace).
+- **`gh` present, not authenticated** → stderr auth pattern → `'unauthenticated'` → "gh auth login".
+- **No PR for branch / no remote / detached HEAD** → `'no-pr'` → "No PR". (Detached HEAD: `.git/HEAD` isn't `ref:` → cache-key branch is `null`; gh itself errors → `no-pr`.)
+- **PR exists but checks not started / all pending** → `checks.total>0, passing=0, pending=total` → "⧗ 0/N".
+- **`mergeable:'UNKNOWN'`** (GitHub computes mergeability async right after a push) → `'unknown'` → neutral marker, not a red "conflict". Only `'conflicting'` shows the ⚠.
+- **Draft PR** → `isDraft:true` → "Draft" styling; still `availability:'ok'`.
+- **Merged/closed PR still on the branch** → `state:'merged'/'closed'` badge (gh returns the most recent PR).
+- **gh timeout** (network hang) → `execFileAsync` kills at `ghTimeoutMs` → `'error'` → generic "PR status unavailable". Bounded, never hangs the request.
+- **Huge `statusCheckRollup`** (100s of checks / monorepo) → bounded by `maxBuffer` (reuse `diffMaxBytes`); overflow → `'error'` (don't try to parse a truncated JSON). Counts are aggregate so the chip stays tiny.
+- **Malformed `--json` output** (gh version drift) → `parsePrView` `JSON.parse` throws → caught → `'error'`.
+- **Branch switch within TTL** → cache key includes HEAD branch, so it busts immediately rather than showing the previous branch's PR for up to 10 s.
+- **FE fetch/network error** → `fetchPrStatus` returns `null` → chip renders an `'error'` state, never throws (mirror `fetchDiff`, `diff.ts:117`).
+
+---
+
+## Security
+
+- **No shell**: `execFile('gh', [fixed argv])` — identical guarantee to `runGit` (`diff.ts:296`, SEC-M9). The **only** user-influenced input reaching gh is `cwd`, which is the already-validated `repoPath`. No untrusted string is ever placed in argv (gh derives the PR from the branch; we never pass a branch/base/rev). This sidesteps the `?base=` deferral rationale in `diff.ts:18-20`.
+- **Path containment**: route calls `isValidGitDir(target)` (`server.ts:123`) before spawning — absolute + is-dir + has `.git` (SEC-H7). Path traversal / arbitrary-cwd is blocked exactly as `/projects/diff`.
+- **DoS bounds**: `timeout: ghTimeoutMs` (hard kill) + `maxBuffer: cfg.diffMaxBytes` bound a slow/huge gh. Module-scope TTL cache + in-flight dedupe cap outbound GitHub-API calls to ≈1 per repo per `projectScanTtlMs` even under rapid detail-view refreshes (the panel auto-refreshes every 5 s, `projects.ts:33`).
+- **Network egress note**: unlike every other side-channel (all local), gh talks to GitHub's API using the host's existing `gh`/`GH_TOKEN` credential. The endpoint **never accepts or forwards a token** — it only triggers gh's own auth. Document this in the route comment and in TECH_DOC §7 (this is the first feature to make an outbound call on behalf of a LAN client; the `GH_ENABLED=0` kill-switch lets a cautious operator disable it entirely).
+- **No secret leakage**: never log gh **stdout** (may contain private PR titles) or the token. If logging a failure, log only `availability` + `sanitizeForLog(stderr.slice(0,200))` (reuse `server.ts:162`) — never raw stderr, mirroring the worktree audit line (`server.ts:714`) and SEC-M10 "never raw git stderr".
+- **Origin/CSRF**: GET is read-only and non-mutating → no Origin guard, consistent with `/projects` and `/projects/diff` (`server.ts:660`). It spawns a subprocess but performs no state change, so CSWH/CSRF risk is limited to triggering a cached, rate-bounded read.
+- **XSS**: PR `title` is attacker-controllable (anyone who can open a PR on a repo the host has access to). It is carried verbatim server-side and rendered **only via `textContent`** in `gh-chip.ts` (SEC-H4, same discipline as `diff.ts` line-rendering). Explicit jsdom test (step 12) asserts no element injection.
+- **Rate-limit**: no per-IP limiter needed (read-only, same as `/projects/diff`); the TTL cache is the effective throttle. If desired later, the `createRateLimiter` helper (`server.ts:109`) is available.
+
+---
+
+## Effort & dependencies
+
+- **Effort**: ~1.5–2 days. Backend `gh.ts` + route ≈ 0.75 day (the runner/cache pattern is a near-copy of `diff.ts`/`projects.ts`; the pure `parsePrView`/`summarizeChecks` classifier is the real work). FE `gh-chip.ts` + wiring + CSS ≈ 0.5 day. Tests (3 files) ≈ 0.5 day, with the PATH-shim integration harness the only novel piece.
+- **Depends on**: nothing hard-blocking — `Config`/`src/types.ts`/`config.ts` coordination edits and `isValidGitDir` all exist today. Requires `gh` on the host for the live path, but the feature is designed to degrade cleanly without it (so it ships regardless).
+- **Unlocks**: the **W3 "Quick wins" chip** (task #11 — sync/ahead-behind chip, recent commits) can reuse the same `gh.ts`/`gh-chip.ts` side-channel + short-TTL-cache scaffold (e.g. `gh pr status`, `git rev-list --count @{u}...HEAD`). The PATH-shim gh-integration harness is reusable by any future gh-backed feature.
+- **Interacts with (no conflict)**: **W3 `?base=` diff** (task #9) touches `diff.ts`/`/projects/diff` only; this feature is an independent file (`gh.ts`) and route (`/projects/pr`). Both add a chip/section to the same `renderProjectDetail` header — coordinate the header layout (place the PR chip after the branch chip, before or beside the diff toggle) but they do not edit the same functions.
\ No newline at end of file
diff --git a/docs/plans/w3-quick-wins.md b/docs/plans/w3-quick-wins.md
new file mode 100644
index 0000000..4c2fc2f
--- /dev/null
+++ b/docs/plans/w3-quick-wins.md
@@ -0,0 +1,174 @@
+# Quick wins: sync chip + cost guard + reconnect digest + recent commits
+
+Four small, independent features that ride existing seams. None touches the byte-shuttle WS stream; all new HTTP routes are side-channel and follow the established `/projects/*` / `/hook/*` patterns. Read-only routes get no Origin guard (same threat model as `/projects` — `src/server.ts:281`); the cost guard rides the already-injected `NotifyService` DI seam and the `stuckNotified` one-shot-latch pattern (`src/session/manager.ts:271-287`).
+
+Shared coordination edit up front: **`src/types.ts`** gains `ProjectInfo.ahead/behind/lastCommitMs` (§a), `NotifyClass` `'budget'` + `Session.budgetNotified` + `Config.costBudgetUsd` + `UiConfig.costBudgetUsd` (§b), `DigestResult`/`DigestSession` (§c), `CommitLogEntry`/`GitLogResult` (§d). Do all type edits in one commit so the four sub-features can then proceed in parallel.
+
+---
+
+## Contract
+
+### (a) Sync chip — no new route
+Folded into the existing `GET /projects` payload. `ProjectInfo` gains three optional fields, populated in the cached per-repo metadata pass:
+
+```ts
+// src/types.ts — extend ProjectInfo (currently line 279-287)
+export interface ProjectInfo {
+  name: string
+  path: string
+  isGit: boolean
+  branch?: string
+  dirty?: boolean
+  lastActiveMs?: number
+  ahead?: number        // commits on HEAD not on @{u}   (git rev-list, right count)
+  behind?: number       // commits on @{u} not on HEAD   (git rev-list, left count)
+  lastCommitMs?: number // git log -1 --format=%ct * 1000 (HEAD commit time)
+  sessions: ProjectSessionRef[]
+}
+```
+`@{u}` with no upstream / non-git → all three `undefined` (best-effort, never throws). No new env var: gated by the existing `projectDirtyCheck` (which already means "spend git subprocess time per repo").
+
+### (b) Cost budget guard + push alert
+- **New env** `COST_BUDGET_USD` (dollars, float ≥ 0; default `0` = disabled) → `Config.costBudgetUsd: number` (`src/types.ts` Config, alongside B2 `statuslineTtlMs` at line 65).
+- **New `NotifyClass` member** `'budget'` (`src/types.ts:376`): `'needs-input' | 'done' | 'stuck' | 'budget'`.
+- **New `Session` field** `budgetNotified: boolean` (`src/types.ts` Session, next to `stuckNotified` at line 227-228) — one-shot latch, **never re-armed** (cost is monotonic).
+- **`GET /config/ui`** payload gains `costBudgetUsd?: number` so the FE can derive warn-styling client-side:
+  ```ts
+  export interface UiConfig { allowAutoMode: boolean; costBudgetUsd?: number }
+  ```
+- **No new ServerMessage variant.** The "warning broadcast" is the *existing* `{type:'telemetry', telemetry}` frame (already broadcast on every statusLine, `manager.ts:260`); the warn is derived on the client (`costUsd >= costBudgetUsd`). The distinct new server action on threshold crossing is a single `notifyService.notify(session, 'budget')` push. (Rationale: keeps the frozen `ServerMessage` contract in `types.ts:109-120` untouched — KISS/YAGNI. A dedicated frame was considered and rejected: cost overage is not a `ClaudeStatus`.)
+- `renderTelemetryGauge` (`public/preview-grid.ts:197`) gains a 4th param `costBudgetUsd?: number`; the cost chip (line 224-227) gets class `tg-cost-warn` when `telemetry.costUsd >= budget`, mirroring the ctx>80% path at line 219.
+
+### (c) While-you-were-away reconnect digest
+**New read-only route** `GET /digest?since=` (no Origin guard; same as `/live-sessions`). Aggregates `manager.list()`:
+
+```ts
+export interface DigestSession {
+  id: string
+  title?: string          // last cwd segment
+  status: ClaudeStatus
+  costUsd?: number         // telemetry.costUsd
+  lastOutputAt?: number
+  finished: boolean        // status==='idle' && lastOutputAt > since
+  needsInput: boolean      // status==='waiting'
+  stuck: boolean           // status==='stuck'
+}
+export interface DigestResult {
+  since: number
+  generatedAt: number
+  total: number
+  finished: number
+  needsInput: number
+  stuck: number
+  working: number
+  totalCostUsd: number
+  sessions: DigestSession[]
+}
+```
+Response: `200` + `DigestResult` (empty aggregate when no sessions). `since` clamped to a finite non-negative number (bad/absent → `0`, i.e. "everything is new").
+
+### (d) Recent-commits log per project
+**New read-only route** `GET /projects/log?path=&n=` (no Origin guard; guarded by `isValidGitDir`, `src/server.ts:123`, exactly like `/projects/diff` at line 661-678).
+
+```ts
+export interface CommitLogEntry { hash: string; at: number; subject: string } // at = %ct*1000
+export interface GitLogResult { commits: CommitLogEntry[]; truncated: boolean }
+```
+- `path` missing/empty → `400`; not a valid git dir → `404` (three-prong `isValidGitDir`); git failure → `500`.
+- `n` parsed to int, clamped to `[1, GIT_LOG_MAX]` (const `50`; default `20`).
+- Git command (NUL-record, US-field delimited so subjects with tabs/newlines can't corrupt parsing):
+  ```
+  git log --no-color -z -n  --format=%h%x1f%ct%x1f%s   (cwd: repoPath, timeout, maxBuffer)
+  ```
+
+---
+
+## Files to change
+
+| Path | Change |
+|---|---|
+| `src/types.ts` | **Coordination edit.** Add `ProjectInfo.ahead/behind/lastCommitMs`; `NotifyClass` `'budget'`; `Session.budgetNotified`; `Config.costBudgetUsd`; `UiConfig.costBudgetUsd`; new `DigestResult`/`DigestSession`, `CommitLogEntry`/`GitLogResult`. |
+| `src/config.ts` | Add `parseNonNegativeFloat(raw,label,fallback)` helper; parse `COST_BUDGET_USD`→`costBudgetUsd` (near B2 block line 365-369); add `costBudgetUsd` to the frozen object (spread block line 415-437). |
+| `src/http/projects.ts` | Add `readSync(repoPath)` helper (2 execFile calls); extend `MakeProjectArgs` (line 121) + `makeProject` (line 129) with `ahead/behind/lastCommitMs`; call `readSync` in `runDiscovery`'s per-repo `mapWithConcurrency` (line 276-280), gated by `cfg.projectDirtyCheck`. |
+| `src/http/git-log.ts` | **New.** `parseGitLog(stdout,max)` (pure) + `getGitLog(repoPath,{n,timeoutMs})` (async, execFile, no shell). Modelled on `src/http/diff.ts` / `readDirty` (`projects.ts:98`). |
+| `src/http/digest.ts` | **New.** `buildDigest(live: readonly LiveSessionInfo[], since: number): DigestResult` — pure, injected list (mirrors `buildProjects` injection, `projects.ts:387`). |
+| `src/session/manager.ts` | In `handleStatusLine` (line 256): after storing/broadcasting telemetry, run the budget-latch check → `notifyService?.notify(session,'budget')`. |
+| `src/session/session.ts` | Init `budgetNotified: false` in `createSession` object literal (~line 138). **Do not** re-arm (leave line 150 as-is). |
+| `src/server.ts` | Add `GET /projects/log` (after `/projects/diff`, line 678); add `GET /digest` (after `/live-sessions`, line 279); extend `GET /config/ui` (line 728-731) with `costBudgetUsd`. Import `getGitLog`, `buildDigest`. |
+| `public/preview-grid.ts` | `renderTelemetryGauge` gains `costBudgetUsd?` param; add `tg-cost-warn` class on the cost chip (line 224-227). |
+| `public/projects.ts` | `normalizeProject` (line 236): pass through numeric `ahead/behind/lastCommitMs`. `makeProjectCard` (line 454): add sync chip after branch chip. `renderProjectDetail` (line 709 area, git repos only): mount recent-commits section. |
+| `public/git-log.ts` | **New.** `mountGitLog(container, repoPath)` — fetch `GET /projects/log`, render inert rows via `textContent` only. |
+| `public/digest.ts` | **New.** `mountDigest(host)` — read `localStorage` last-seen, fetch `GET /digest?since=`, render dismissible banner, update last-seen. |
+| `public/tabs.ts` | In `loadUiConfig` (line 248-261): also read `costBudgetUsd`; store on the instance; pass to `renderTelemetryGauge` call (line 1391). |
+| `public/main.ts` | `mountDigest(...)` in the toolbar/app wiring block (~line 51-119). |
+| `public/style.css` | Add `.tg-cost-warn`, `.proj-sync`, `.proj-commitlog` rows, `.wya-banner` (mirror `.tg-ctx-warn`, `.proj-branch`). |
+
+---
+
+## TDD steps (ordered)
+
+Repo style: backend unit tests are **node** env (default), FE tests declare `// @vitest-environment jsdom` at the top (`test/projects-panel.test.ts:1`) and mock `@xterm/xterm`; route tests spin a real server on a free port (`test/integration/projects-endpoint.test.ts`). `test/manager.test.ts` uses a mock `NotifyService` recording `notify(session,cls,token)` (line 100-107) and a `parseSent(ws)` helper.
+
+**0. Types (RED→GREEN, compile-only)**
+- [ ] Edit `src/types.ts` with all shapes above. `npx tsc --noEmit` fails where callers/mocks lack new required fields → gives the worklist. `Session.budgetNotified` is required → `test/manager.test.ts` + `src/session/session.ts` must init it.
+
+**(b) Cost guard — highest security value, do first**
+- [ ] `test/config.test.ts`: add defaults block — `loadConfig({}).costBudgetUsd === 0`; override `COST_BUDGET_USD:'5.50'`→`5.5`; `throw` for `'abc'` and `'-1'` (mirror line 156-181). Implement `parseNonNegativeFloat` + wire in `src/config.ts`.
+- [ ] `test/manager.test.ts` (extend `describe('handleStatusLine')` line 803): with `cfg.costBudgetUsd=1`, feed telemetry `costUsd=0.5` → `notify` **not** called, `session.budgetNotified===false`; feed `costUsd=1.2` → `notify` called once with `(s,'budget')`, latch `true`; feed `costUsd=2` again → **not** called again. With `costBudgetUsd=0` → never called. Implement the latch in `manager.handleStatusLine`.
+- [ ] `test/session.test.ts`: assert `createSession(...).budgetNotified === false`. Implement init in `session.ts`.
+- [ ] `test/preview-grid.test.ts` (jsdom): `renderTelemetryGauge(c, {costUsd:6,at:now}, ttl, 5)` → cost chip has class `tg-cost-warn`; with budget `0`/`undefined` or `costUsd` → 200 array; missing `path`→400; non-git temp dir→404; `?n=999`→clamped. Implement route in `server.ts`.
+- [ ] FE `test/git-log.test.ts` (jsdom): `mountGitLog` renders rows via `textContent`; a commit subject containing `` appears verbatim (no HTML injection); fetch failure → empty/error inert text. Implement `public/git-log.ts` + detail-section wiring in `public/projects.ts`.
+
+**(a) Sync chip**
+- [ ] `test/projects.test.ts` (extend, node): real temp repo (uses `execFileP` already imported line ~20) with an upstream branch ahead/behind → `buildProjects` yields `ahead`/`behind`/`lastCommitMs`; repo with **no** upstream → those `undefined`, no throw; `projectDirtyCheck:false` → sync skipped (undefined). Implement `readSync` + fold into `runDiscovery`.
+- [ ] `test/projects-panel.test.ts` (jsdom): `normalizeProject` passes numeric `ahead/behind/lastCommitMs`, drops non-numbers; `makeProjectCard` renders `↑2 ↓1` chip when set, omits when `undefined`/`0`. Implement FE.
+
+**(c) Reconnect digest**
+- [ ] `test/http/digest.test.ts` (new, node): `buildDigest([...LiveSessionInfo], since)` — counts finished (`idle` & `lastOutputAt>since`), needsInput (`waiting`), stuck, working; `totalCostUsd` sums `telemetry.costUsd`; empty list → zeroes; `since` in the future → 0 finished.
+- [ ] `test/integration/digest-endpoint.test.ts` (new): server up, `GET /digest?since=0` → 200 `DigestResult`; malformed `since` → treated as 0. Implement route.
+- [ ] FE `test/digest.test.ts` (jsdom): `mountDigest` fetches with the stored last-seen; renders banner only when `finished+needsInput+stuck>0`; dismiss updates localStorage last-seen and hides; fetch failure → no banner (best-effort). Implement `public/digest.ts` + `main.ts` mount.
+
+**Coverage:** every new pure function (`parseGitLog`, `buildDigest`, `readSync` via `buildProjects`, budget latch, gauge warn) has a direct unit test; routes have integration tests. Keeps the ≥80% gate — the new code is mostly pure/tested; the thin `server.ts` wiring is exercised by the integration tests.
+
+---
+
+## Edge cases & failure modes
+
+- **(a)** No upstream (`@{u}` fatal) → catch → `ahead/behind` undefined; chip hidden. Detached HEAD → `git log -1 --format=%ct` still works (lastCommitMs set), `@{u}` fails (sync hidden). Empty repo (no commits) → both git calls fail → all undefined. Slow git → 2s timeout kill (reuse `GIT_STATUS_TIMEOUT_MS`, `projects.ts:36`). Adds ≤2 spawns/repo bounded by `GIT_CONCURRENCY=8`; gated off entirely when `projectDirtyCheck=false`. `ahead=behind=0` (in sync) → chip omitted (only render when >0).
+- **(b)** `costUsd` undefined in a telemetry frame → skip guard (no crossing). Budget `0` → disabled. Latch persists across statusLine frames; a **new** session gets its own latch (per-`Session`). DND/`notifyDone` interplay: `'budget'` is neither `'done'` nor gated, so `shouldSend` sends it unless global DND is on (matches stuck). Late-joining device: gets current telemetry via `manager.ts:139-140`, derives warn from `/config/ui` budget — no missed styling. Push disabled (no VAPID) → latch still flips, broadcast still happens, just no push (graceful, like stuck).
+- **(c)** `since` absent/NaN/negative → `0`. No sessions → all-zero `DigestResult` (banner suppressed client-side). Clock skew / `lastOutputAt` in future → still counted as finished if `idle` (acceptable; coarse "what happened" view). First-ever visit (no localStorage) → `since=0`, banner may list everything → set last-seen to `generatedAt` after first render so it doesn't re-nag.
+- **(d)** Binary/huge subjects: `maxBuffer` cap + subject truncation. Non-repo/deleted path → 404 (isValidGitDir). Repo with 0 commits → `[]`. Merge commits/unusual chars in subject → US-field + NUL-record delimiters immune to embedded whitespace. `n` non-numeric → clamp to default.
+
+---
+
+## Security
+
+- **Path containment:** `/projects/log` reuses `isValidGitDir` (`server.ts:123`) — absolute + isDirectory + has `.git` (SEC-H7 three-prong), identical to `/projects/diff`. `readSync` runs only against paths already discovered by the bounded BFS scan (`scanRepos`, symlink/dotdir/`node_modules`-skipping, `projects.ts:148`).
+- **No shell, ever:** all git calls use `execFile('git', [...])` with `timeout` + `maxBuffer` (mirrors `readDirty` line 100). `repoPath` is passed as `cwd`, never interpolated into argv. `n` is coerced to an int and clamped before reaching argv.
+- **Output is untrusted:** commit subjects and digest labels rendered via `textContent` only (SEC-H5, as in `preview-grid.ts` and `projects.ts` worktree/CLAUDE.md rendering) — zero `innerHTML`. FE `normalizeProject`-style narrowing for the new numeric fields (drop non-numbers) and for `/digest`/`/projects/log` responses (never trust the API shape).
+- **Origin/CSRF:** all four routes are **read-only GETs** → no Origin guard, consistent with `/projects`, `/live-sessions`, `/projects/diff`. `GET /config/ui` stays read-only. No state-changing surface is added, so no new `requireAllowedOrigin` / rate-limit needed. (The budget **push** goes out the existing `pushService` seam — no new inbound route.)
+- **Secrets:** cost budget is a non-secret number; `costBudgetUsd` is safe to expose in `/config/ui`. Push payload for `'budget'` carries only sessionId + cwd-basename label (no cost figure, no terminal bytes) via the existing `buildPayload` (`push-service.ts:88`) — SEC-C5 byte-shuttle boundary preserved.
+- **DoS:** sync adds bounded spawns (concurrency 8, 2s timeout, gated by `projectDirtyCheck`); `/projects/log` `n` clamped ≤50; `/digest` is O(sessions) over an already-capped table.
+
+---
+
+## Effort & dependencies
+
+**~3–4 dev-days total** (four independent slices; can be parceled to parallel builders after the shared `src/types.ts` edit lands):
+
+| Sub | Effort | Notes |
+|---|---|---|
+| (a) sync chip | ~0.5–0.75d | Backend `readSync` + fold-in + 2 FE renders. |
+| (b) cost guard | ~0.75d | Config float parser, manager latch, gauge warn, `/config/ui` + tabs.ts wiring. Highest test surface. |
+| (c) reconnect digest | ~1d | New pure aggregate + route + new FE banner module + main.ts mount + localStorage last-seen. |
+| (d) recent commits | ~1d | New backend git-log module + route + new FE render module + detail-section wiring. |
+
+**Dependencies (into these):** all four build only on already-shipped infra — `buildProjects` cache pass (v0.6), `NotifyService` DI + `stuckNotified` latch (A5/A1), `renderTelemetryGauge` (B2), `isValidGitDir` + `getDiff` pattern (B1), `/config/ui` seam (review #4). No dependency on other roadmap items.
+
+**Unlocks / synergy:** (d)'s `getGitLog` + NUL-parsing helper and (a)'s upstream detection are reusable by **#9 "diff against a base branch"** (upstream/`@{u}` resolution) and **#10 "PR + CI status chip"** (a per-repo git/`gh` metadata pass can fold into the same `runDiscovery` concurrency slot as the sync chip). (c)'s `buildDigest` gives **#8 idle-queued follow-up** a ready read-side "which sessions are idle/waiting" aggregate. `NotifyClass 'budget'` establishes the pattern for any future threshold alerts.
\ No newline at end of file
diff --git a/docs/plans/w4-commit-push.md b/docs/plans/w4-commit-push.md
new file mode 100644
index 0000000..c1b47c9
--- /dev/null
+++ b/docs/plans/w4-commit-push.md
@@ -0,0 +1,135 @@
+# Stage / commit / push from the diff viewer
+
+`id: w4-commit-push` — the highest-risk git **write** set. Scope is bounded to the MVP the task defines: **per-file stage/unstage toggle + commit + push-current-branch**. Discard / checkout / restore-working-tree are explicitly **deferred** (they are the only destructive-to-working-tree ops; nothing in this feature ever touches file contents on disk).
+
+This mirrors the existing read-only diff channel (`src/http/diff.ts`) and the one existing git-write channel (`src/http/worktrees.ts` + the `POST /projects/worktree` route at `src/server.ts:699–725`). The server stays a byte-shuttle; these are out-of-band side-channel routes.
+
+---
+
+## Contract
+
+### New routes (all in `src/server.ts`, all `express.json`, all `requireAllowedOrigin` + `isValidGitDir`)
+
+Every route: `requireAllowedOrigin(req, res)` (CSRF, `src/server.ts:352`) → `gitOpsEnabled` gate (403 if off) → per-IP rate limit → validate `path` with the in-file `isValidGitDir` (three-prong: absolute + dir + `.git`, `src/server.ts:123`) → delegate to `src/http/git-ops.ts`. The delegate re-validates and **realpath-contains** every path (defense in depth — the route’s `isValidGitDir` does not follow symlinks).
+
+| Method + path | Body | Success | Notes |
+|---|---|---|---|
+| `POST /projects/git/stage` | `{ path: string, files: string[], stage?: boolean }` | `200 {ok:true, staged:boolean, count:number}` | `stage` default `true` → `git add`; `false` → unstage (`git restore --staged`). `stage` is an **addition** to the task’s bare `{path,files[]}` so one endpoint serves the toggle in both directions. |
+| `POST /projects/git/commit` | `{ path: string, message: string }` | `200 {ok:true, commit:string}` (short SHA) | Commits **staged** changes only. `message` capped at `commitMsgMaxLen`. |
+| `POST /projects/git/push` | `{ path: string }` | `200 {ok:true, branch:string, remote:string}` | Pushes the **current branch** to its existing upstream, else `-u  `. Remote/branch are **derived server-side**, never taken from the client. |
+
+Error shape (mirrors the worktree route at `src/server.ts:724`): `res.status(result.status).json({ error: result.error })` where `error` is a **safe** message (never raw git stderr, SEC-M10 — see `classifyWorktreeError`, `src/http/worktrees.ts:193`).
+
+Status codes: `400` invalid input / detached HEAD / no remote / identity unset; `403` disabled or bad Origin; `404` not a git dir; `409` nothing staged / non-fast-forward / index.lock held; `401` push auth required; `429` rate-limited; `500` unclassified; `200` success.
+
+### `src/types.ts` (coordination edit — frozen shared-contract source)
+
+Add, next to `CreateWorktreeResult` (`src/types.ts:485`):
+
+```
+export interface GitOpResult {
+  ok: boolean;
+  status?: number;      // HTTP status on failure
+  error?: string;       // SAFE message only (never raw git stderr)
+  // success payloads (route-specific, all optional):
+  staged?: boolean;     // stage: direction applied
+  count?: number;       // stage: files affected
+  commit?: string;      // commit: short SHA
+  branch?: string;      // push: branch pushed
+  remote?: string;      // push: remote pushed to
+}
+```
+
+One interface keeps the coordination churn minimal (like `CreateWorktreeResult`). The FE narrows with an `isGitOpResult` guard mirroring `isWorktreeResult` (`public/projects.ts:545`).
+
+### `src/config.ts` env vars (4 new, mirroring the B3 worktree group at `src/config.ts:371–378`)
+
+| Env | Field | Default | Purpose |
+|---|---|---|---|
+| `GIT_OPS_ENABLED` | `gitOpsEnabled: boolean` | `true` | Master kill-switch (mirrors `worktreeEnabled`, `src/config.ts:372`). Off → all three routes 403. |
+| `GIT_OPS_TIMEOUT_MS` | `gitOpsTimeoutMs: number` | `10_000` | stage/commit exec timeout (mirrors `DEFAULT_WORKTREE_TIMEOUT_MS`, `src/config.ts:65`). |
+| `GIT_PUSH_TIMEOUT_MS` | `gitPushTimeoutMs: number` | `120_000` | push is network-bound → longer bound. |
+| `COMMIT_MSG_MAX_LEN` | `commitMsgMaxLen: number` | `5_000` | commit-message length cap. |
+
+Parsed with the existing `parseBool` / `parseNonNegativeInt` helpers and appended to the frozen object (`src/config.ts:413–438`). File-count cap for `stage` reuses the existing `diffMaxFiles` (`src/config.ts:358`, default 300) — no new var. `src/types.ts` `Config` interface gains the 4 fields (same coordination note the file already carries at `src/config.ts:15`).
+
+---
+
+## Files to change
+
+| Path | Change |
+|---|---|
+| **`src/http/git-ops.ts`** (new) | The whole write engine. Mirrors `diff.ts`/`worktrees.ts` execFile pattern: no shell, `timeout`+`maxBuffer`, `--` terminator, `env:{...process.env, GIT_TERMINAL_PROMPT:'0'}`. Exports (all never-throw, return `GitOpResult`): `stageFiles(repoPath, files, stage, opts)`, `commit(repoPath, message, opts)`, `push(repoPath, opts)`, plus pure `classifyGitError(stderr): {status,error}` and `validateRepoFiles(repoRealPath, files): string[] \| null`. |
+| **`src/http/git-path.ts`** (new, small) | Extracts the containment helper so it isn’t duplicated: `resolveRealPath(target)` (copy of `worktrees.ts:118`) + `isContained(realBase, realCandidate): boolean`. Used by `git-ops.ts`. (Optional follow-up: refactor `worktrees.ts:141` `computeWorktreeDir` onto it — **deferred**, out of this task’s lane.) |
+| **`src/types.ts`** | Coordination edit: add `GitOpResult` (above) + 4 `Config` fields. |
+| **`src/config.ts`** | Parse the 4 new env vars; append to the frozen config object. |
+| **`src/server.ts`** | Register the 3 routes after the worktree route (`:725`). Add two module-level rate constants + two limiters via `createRateLimiter` (`:109`). Reuse in-file `isValidGitDir`, `requireAllowedOrigin`, `sanitizeForLog` (`:162`) for the commit/push audit log. Import from `./http/git-ops.js`. |
+| **`public/diff.ts`** | `mountDiffViewer` (`:253`) gains: per-file **Stage/Unstage** toggle button in each file row, and a bottom **commit/push bar** (message `