@dieulc/workflow 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ Plan↔Build mode with an independent **Review Mode** reviewer, questionnaire, s
8
8
  - **Review Mode** — an independent reviewer agent that runs **after** Plan Mode finishes (sequential — never in parallel), in its own in-process session. It audits the plan, rewrites it, streams its work into the main transcript, and hands the result to Plannotator. Off by default; see [Review Mode](#review-mode) below.
9
9
  - **Questionnaire** — 1–4 questions with headers/options, dedup freeform aliases, and an inline `↳ Your answer:` editor. Rendered as a bounded bottom-sheet overlay: a short question and the choices stay pinned, while a long question or the optional `context` body (e.g. a proposed commit message) scrolls with the wheel, `PgUp`/`PgDn` or `Ctrl+U`/`Ctrl+D`. The freeform editor takes the pane while typing.
10
10
  - **Explore (subagents)** — parallel `pi --mode json` subagents (`max 8`, concurrency 4), `session_shutdown` kill, `activeSubagents`
11
- - **Todos** — plan-faithful todo list (global step refs + statuses + phase groups) that persists across Plan/Build/Default modes. `workflow_todo` (`list/update/done/pending/add/toggle/sync/clear`) with industry-style wholesale `update` (send the COMPLETE list; omitted items are removed) plus idempotent `done`/`pending`, lenient `[DONE:<ref>]` markers, throttled Claude-Code-style reminders, a tool-result footer, and a stale ⚠ notice — but **no forced turns and no heuristic auto-completion** (reference: Claude Code / OpenCode / Codex practice).
11
+ - **Todos** — plan-faithful todo list (global step refs + statuses + phase groups) that persists across Plan/Build/Default modes. `workflow_todo` (`list/update/done/pending/add/toggle/sync/clear`) with industry-style wholesale `update` (send the COMPLETE list; omitted items are removed) plus idempotent `done`/`pending`, lenient `[DONE:<ref>]` markers, throttled Claude-Code-style reminders, a tool-result footer, and a stale ⚠ notice — but **no forced turns and no heuristic auto-completion** (reference: Claude Code / OpenCode / Codex practice). A pinned panel shows the active list, collapses to one summary line when every step settles, and is controlled by `/todos show|expand|collapse|hide|auto` or `Ctrl+Alt+O`.
12
12
  - **Rewind** — shadow bare-repo checkpoints per cwd (`agent/checkpoints/checkpoint-<slug>.git`), `isDirty` + `createSafetySnapshot` + `restoreCode`
13
13
  - **Plannotator bridge** — `plannotator:request/review-result/plan-approved` events, inline markdown handoff (`ScrollView` → inline scrollback), `getUtcDatePrefix` / `normalizePlanPath` for `.pi/plans/<UTC-date>-<slug>.md`
14
14
  - **Model roles** — Planner/Explorer/Builder roles with one model + thinking level each (`roles.ts`, `~/.pi/agent/roles.json` v2). `/plan` switches to the planner model, `/build` to the builder model, `explore` subagents use the explorer model. `/role` opens a tabbed picker (scoped models first, `Ctrl+O` for all). The thinking list follows the selected model's `thinkingLevelMap`; unsupported stored levels are clamped on save/activation with a warning.
@@ -33,10 +33,10 @@ Manage it with `pi list`, `pi update --extensions`, `pi remove npm:@dieulc/workf
33
33
  | `/plan` / `/workflow` | Enter plan mode (tool gating + switches session model to planner model `high` thinking) |
34
34
  | `/build` | Enter build mode (full tools + switches session model to builder model `medium` thinking) |
35
35
  | `/rewind` | Pick checkpoint to restore |
36
- | `/todos` | Show todo list |
36
+ | `/todos` | List steps, or control the pinned panel: `/todos [show\|expand\|collapse\|hide\|auto\|clear]`. No arg lists everything; `clear` asks first when UI is available (headless clears immediately) |
37
37
  | `/btw` | Queue note for next turn |
38
38
  | `/role` | Config screen: one model + thinking per role (planner/explorer/builder/reviewer tabs, custom roles included; type to filter models by `provider/id` or display name). CLI: `list`, `set <role> <provider/model> [thinking]`, `use <role\|auto>`, `add/remove/reset` |
39
- | `/profile` | Switch or manage model profiles (named role-model combinations). Picker: activate / edit / rename / delete / New profile… / Default… (fuzzy type-to-filter; completed actions close the picker). CLI: `list`, `show <name>`, `use <name\|none>`, `create <name>`, `edit <name>`, `rename <old> <new>`, `delete <name>`, `default [name\|none] [global\|project]` |
39
+ | `/profile` | Switch or manage model profiles (named role-model combinations). Picker: activate / edit / rename / delete / New profile… / Default… (fuzzy type-to-filter; completed actions close the picker). CLI: `list`, `show <name>`, `use <name\|none>`, `create <name>`, `edit <name>`, `rename <old> <new>`, `delete <name>`, `default [name\|none] [global\|project]`, `retarget-provider <old> <new> [global\|project] [--apply]` |
40
40
  | `/review-mode [on\|off] [global\|project]` | Enable/disable Review Mode (default `off`). `status` prints the effective config, naming the settings file that supplied the timeout |
41
41
  | `/review-mode timeout <seconds\|unlimited> [global\|project]` | Set or show the per-pass time cap (`unlimited`/`0` = no cap). Writes to global settings by default |
42
42
  | `/review-prompt [global]` | Edit the reviewer's guidance (defaults to "align with the existing framework and industry best practice") |
@@ -45,9 +45,11 @@ Manage it with `pi list`, `pi update --extensions`, `pi remove npm:@dieulc/workf
45
45
  | `/commit` | Commit staged changes with an AI-summarized message (`-a`: stage tracked changes first). Confirmation-gated; the confirmation arms the commit exemption for that run |
46
46
  | `/commit-policy [never\|ask\|allow] [global\|project]` | View or set the commit policy (default `never`: the agent cannot `git add`/`commit`/`push` — you commit, or use `/commit`) |
47
47
  | `/write-policy [allow\|ask\|never] [global\|project]` | View or set the write permission for Default mode (default `allow`: the agent writes freely; `ask` prompts per write; `never` blocks writes — see [Write permission](#write-permission)) |
48
+ | `/folder-policy [repo\|cwd\|parent\|any] [global\|project]` | Shared folder scope for read, write **and** commit (default `repo`: nearest `.git` root, fallback cwd). `outside ask\|block`, `grant project\|global`, `templates allow\|block`, `sensitive` |
49
+ | `/read-policy [status\|allow <path>\|revoke <path>\|shell paths\|strict] [global\|project]` | Read grants, shell analysis mode and the shared folder scope. Sensitive files are always blocked |
48
50
  | `/permissions [explain] <command>` | Explain a shell command's verdict without running it: each segment's class, the Plan/Review/Explorer answer and whether the write policy would treat it as a write |
49
51
 
50
- Mode commands (`/plan`, `/build`, `/default`) and the `Tab` / `Ctrl+Alt+P` shortcuts are refused while a review round or a paused review owns the turn (Tab still completes a visible suggestion list); see [Controlling a running review](#controlling-a-running-review).
52
+ Mode commands (`/plan`, `/build`, `/default`) and the `Tab` / `Ctrl+Alt+P` shortcuts are refused while Review owns the turn — `running`, `paused`, waiting on a handoff decision, or applying one (Tab still completes a visible suggestion list); see [Controlling a running review](#controlling-a-running-review).
51
53
 
52
54
  ## Model Profiles
53
55
 
@@ -145,7 +147,10 @@ Plan Mode and Review Mode share one role-based command policy (`permissions.ts`)
145
147
  | `read`, `grep`, `find`, `ls` | Inspect the repository |
146
148
  | `review_bash` | Shell access on the same role policy Plan Mode uses: read-only inspection plus **test/lint/typecheck** runners (`npm test`, `npm run lint/typecheck`, `eslint`, `tsc --noEmit`, `node --test`, `pytest`, `cargo clippy`, …). No file redirects (`2>/dev/null` and `2>&1` are allowed), no `sed`/`awk`, no `rm`/`mv`/`cp`/`mkdir`/`touch`, no `sudo`, no package installs, no builds, no `git add/commit/push` |
147
149
  | `review_explore` | Read-only reconnaissance subagents on the **explorer** role's model, capped by `exploreBudget` per round |
148
- | `review_submit_plan` | The **only** write path: validates and saves the revised plan to Plan Mode's own `.pi/plans/<UTC-date>-<slug>.md` |
150
+ | `review_plan_begin` | Starts a **chunked** rewrite: resets the per-round assembler and states the per-chunk size limit |
151
+ | `review_plan_chunk` | Appends one ordered chunk of the rewrite (`index` 1, 2, 3, …). Out-of-order, repeated or oversized chunks are refused with the index that is expected next |
152
+ | `review_plan_finalize` | Turns the accepted chunks into the plan. Refuses an incomplete assembly; goes through the same `review_submit_plan` funnel, so the plan is written exactly once |
153
+ | `review_submit_plan` | The **only** write path: validates and saves the revised plan to Plan Mode's own `.pi/plans/<UTC-date>-<slug>.md`. Also the one-shot path for short plans |
149
154
  | `review_pass_done` | Ends a pass with a verdict (refused if nothing was submitted) |
150
155
 
151
156
  The policy classifies commands into three classes and maps them to roles:
@@ -165,11 +170,33 @@ Compound commands are checked segment-by-segment, and each check reads the view
165
170
 
166
171
  The built-in `bash`, `edit` and `write` tools are never given to the reviewer at all.
167
172
 
173
+ ### Chunked rewrite submission
174
+
175
+ A plan rewrite is the one response in the flow that can be longer than a provider gateway allows a
176
+ stream to live. Nothing is written until a submission succeeds, so a cut mid-response loses the
177
+ whole rewrite and burns the round. Chunking makes the rewrite survive that:
178
+
179
+ 1. `review_plan_begin` (optionally with `totalChunks`) resets the assembler.
180
+ 2. `review_plan_chunk` appends `index` 1, 2, 3, … — each at most `chunkChars` characters. A
181
+ rejected chunk (out of order, repeated, oversized, or cut before it arrived) is answered with
182
+ the index the assembler expects next, so the retry continues **without restarting**: already
183
+ accepted chunks are kept. Calling `review_plan_begin` again is the explicit "throw it away" move.
184
+ 3. `review_plan_finalize` validates completeness (`totalChunks`, when declared) and calls the same
185
+ submit funnel the one-shot path uses — `writePlan` stays the single writer and the review gate
186
+ is notified exactly once.
187
+
188
+ Pass 1 asks for the chunked protocol when `chunkChars > 0` **and** the plan is longer than
189
+ `chunkChars × 2`; shorter plans keep the one-shot `review_submit_plan`. `chunkedSubmit: "off"` or
190
+ `chunkChars: 0` disables chunking entirely (the runtime then refuses `review_plan_begin`). Pass 2
191
+ reads the assembled rewrite and its diff, so a chunked pass 1 feeds the self-verify pass unchanged.
192
+ On abort, teardown, a new round or a provider switch the assembler is cleared, so a partial rewrite
193
+ can never be stitched onto a different attempt.
194
+
168
195
  The child session is created with an explicit tool allowlist: the read-only built-ins **plus every reviewer tool above**. That union is load-bearing — Pi's `createAgentSession({ tools })` option is a *global* allowlist that filters custom tools too, so a built-ins-only list silently removes `review_submit_plan` and makes every review end in a fallback. The runtime therefore reads `getActiveToolNames()` straight after creation and refuses the session (naming the missing tools) rather than starting a review that cannot succeed; the active tool set is also logged once per session.
169
196
 
170
197
  ### Two passes
171
198
 
172
- 1. **Review + rewrite** — audits the plan against the repository and the guidance, records findings, submits the revised plan.
199
+ 1. **Review + rewrite** — audits the plan against the repository and the guidance, records findings, submits the revised plan (one-shot for short plans, `review_plan_begin`/`chunk`/`finalize` when the plan exceeds two chunks).
173
200
  2. **Self-verify** — re-reads the original and the rewrite, confirms every accepted finding was actually addressed, and reports anything that was silently dropped. This pass exists because a rewrite can lose a requirement the author cared about.
174
201
 
175
202
  Pass 2 is skipped when `verify` is false, or when pass 1 left nothing at severity ≥ 7 with an `accepted` disposition — clean and low-severity reviews resubmit pass 1's plan directly. When pass 2 runs it checks a **unified diff** of the rewrite (not two full plans), so the self-check costs roughly a tenth of the tokens.
@@ -215,16 +242,17 @@ Mode changes are reported by Pi's **footer status** (`⏸ plan` / `▶ build`, p
215
242
 
216
243
  These are **display-only custom entries** (`pi.appendEntry` + `pi.registerEntryRenderer`): they are durable in the transcript and survive `/reload`, but they never enter the LLM context, so Plan Mode and Build Mode cannot see the reviewer's work. Consecutive identical bands are suppressed (the gate is seeded from the newest band on reload, and a resumed/paused band is never treated as identical to the band it follows), so cycling modes or re-running a review cannot grow the transcript. Long reviews are coalesced (flushed roughly every 400 ms, and always flushed before a band) and capped, with a one-line notice pointing at `/review-status` when output is truncated.
217
244
 
218
- **Run narration.** The run-start line is emitted by the harness at the moment it sends the prompt, so a resume cannot masquerade as a fresh start: `reviewer started` (a reviewer session was created), `reviewer resumed` (a paused round continues on the warm session), `reviewer continuing` (a later round reuses the session), `reviewer restarting — new reviewer session` (a reviewer-model switch forced a reboot) and `reviewer verifying — pass 2`. A run ends with `reviewer run ended`.
245
+ **Run narration.** The run-start line is emitted by the harness at the moment it sends the prompt, so a resume cannot masquerade as a fresh start: `reviewer started` (a reviewer session was created), `reviewer resumed` (a paused round continues on the warm session), `reviewer continuing` (a later round reuses the session), `reviewer restarting — new reviewer session` (a reviewer-model switch forced a reboot) and `reviewer verifying — pass 2`. A run ends with `reviewer run ended`. An abort **Review Mode requested itself** (pass complete, pause, teardown) is silent; an unexpected abort shows only that neutral line; `reviewer run ended on a provider error: …` is reserved for a real provider or transport failure.
219
246
 
220
247
  #### Where your next message goes
221
248
 
222
- Input follows the **active mode**, decided automatically — there is nothing to focus or toggle:
249
+ Review is an **exclusive turn**: while it owns the session (`running`, `paused`, `handoff`, `transition`), prompts are locked. Only `/review…` controls, `/model`, `/thinking`, `/profile` and `/role` pass the input gate; anything else is refused with a notice instead of becoming a queued turn. When no review owns the turn, input follows the **active mode**, decided automatically — there is nothing to focus or toggle:
223
250
 
224
251
  - **Plan Mode** → the message goes to the plan agent.
225
- - **Review Mode** (a review round is in flight) → plain chat **stays with the plan agent**. Use `/review say <message>` to steer the in-flight reviewer explicitly (echoed as `you → reviewer`); `/btw <note>` always queues a note for the next turn, even mid-review.
226
252
  - **Neither** (including while the plan sits in Plannotator) → the message goes to the plan agent, so you can still refine the plan.
227
253
 
254
+ Use `/review say <message>` to steer the in-flight reviewer explicitly (echoed as `you → reviewer`); `/btw <note>` queues a note for the next turn when no review owns the turn.
255
+
228
256
  Delivery to the reviewer is best-effort: if it fails, the message stays in the plan-agent chat rather than being swallowed.
229
257
 
230
258
  **Status line.** While a review is running, the footer status carries a review segment alongside the mode (`⏸ plan … · 🔍 review · pass 2/2 · 3 findings · ● verifying`), the widget above the editor shows the same live phase, and the spinner row reads `🔍 Reviewing plan…`. Pausing switches both to the paused form (`🔍 review paused — /review-resume to continue`); resuming returns them to the running form. The line is **live-only**: it disappears as soon as the round lands, fails or is aborted, and on a mode switch — Plan/Build never keeps a stale `🔍 review … aborted` line. A retained terminal state is also hidden during a new round's cold boot, and a pause from an earlier plan cycle is ignored. Starting a new prompt (or an explicit refine/deny) abandons a paused review and clears both the review line and `🔒 modes locked`. `/review-status` still reports the last round's result.
@@ -241,7 +269,9 @@ A paused review is in-memory and belongs to the cycle that recorded it: it canno
241
269
 
242
270
  `/review` resolves its target independently of the handoff, so a plan is never unreachable because of how Plan mode was entered: explicit path → this run's last plan write → the session's last plan write → the last handed-off plan → the newest plan write in the session branch. Missing, empty or unreadable candidates are skipped; the resolved path is named when it came from a fallback; the reviewer writes back to that same file. With no candidate it lists the newest `.pi/plans/*.md` files and the explicit-path syntax. `/review <path>` is restricted to `.pi/plans/`.
243
271
 
244
- - **Mode switching is locked** from the moment a review round starts (including the cold boot) until it lands, fails, or is aborted, and for the whole paused state. `Tab` (when no suggestion list is open), `Ctrl+Alt+P`, `/plan`, `/build`, `/default`, browser approve and external plan-mode changes are refused with one notice, and the footer shows `🔒 modes locked`. `/review-abort` and `/review-mode off` end the lock; the post-review handoff prompt is intentionally unlocked because the independent reviewer has finished.
272
+ **Manual handoff and later rounds.** A settled manual `/review` opens Plannotator with the reviewed plan, even with automatic Review Mode off. In Default/Build, Approve enters Build and Deny switches to Plan before sending feedback. The manual cycle keeps re-reviewing and re-handing off after a Deny until Approve, a fresh prompt or Build. Every new round supersedes an older pending browser gate, so round 2 and later always reopen the current plan; a stale Deny arriving during a round is discarded. After `reload`/`resume`/`fork`, the replacement session asks Plannotator `review-status`: a matching pending review keeps its gate, a completed decision is applied, and a missing/changed review is cleared with a reason. Desktop brings a notification review tab to the front and reloads an existing same-URL review tab; tool-result localhost URLs stay in the background.
273
+
274
+ - **Review is an exclusive turn.** `ReviewTurnPhase` moves `idle → running → paused → handoff → transition → idle`. Every non-idle phase locks both prompts (see [Where your next message goes](#where-your-next-message-goes)) and mode switches: `Tab` (when no suggestion list is open), `Ctrl+Alt+P`, `/plan`, `/build`, `/default`, browser approve and external plan-mode changes are refused with one notice, and the footer shows `🔒 modes locked`. The lock covers the browser/inline handoff and the short decision window, not just the child session, so a queued prompt cannot race the Build/Plan switch. `/review-abort` ends the lock and, with a waiting handoff, hands off the author's plan; `/review-mode off` is no longer offered as an exit while Review owns the turn. Approve is atomic (stop reviewer → Build → follow-up), and a failed Build keeps the handoff visible.
245
275
  - **ESC** pauses the round **everywhere it is active** — while the gated handoff waits, while a manual round runs, with no prompt on screen at all. One long-lived terminal listener maps it to `/review-pause` semantics (warm session kept) and stays registered through the paused window: a second ESC while a gated review is paused is swallowed with a hint instead of aborting the parked plan turn (that abort used to poison the turn's signal and stall every `/review-resume`). With no review owning the turn it passes ESC through, so the editor, questionnaire and handoff selector keep it. Headless/RPC hosts have no listener and keep `/review-abort` as their stop path.
246
276
  - `/review-pause` is the same pause as a command. Pausing mid-handoff does **not** render the handoff — the gate stays open until you resume or abort.
247
277
  - `/review-resume` continues the paused round on the warm session (or starts a fresh round if the plan changed since the pause), and settles the waiting handoff with the result. A resumed round is **running** again — the notice/footer return to the running form, and a second ESC can pause it. It runs on a fresh controller and deliberately does **not** inherit the parked turn's signal, so an interrupted parent turn cannot instantly re-abort every resume; ESC, `/review-pause` and `/review-abort` are its stop paths. The transcript says so explicitly (`▌ REVIEW MODE resumed · round N · model` followed by `reviewer resumed`). A rewrite the reviewer wrote to the plan file before the pause is **not** mistaken for an edit of yours: resume compares the file against a hash snapshot taken at pause time (refreshed when the round settles), so only a change made after the pause starts a fresh round. Its notice names the real outcome (rewritten, unchanged, paused, aborted, skipped or fallback).
@@ -303,10 +333,46 @@ Every bridged method is feature-detected: on a Pi version without a given access
303
333
 
304
334
  A stored credential (`/login <provider>` → `auth.json`) always wins over the `apiKey` template, so `$MY_API_KEY` needs no environment variable while you are logged in. Extensions that register the same provider at runtime keep working: their live model list replaces the static one in the parent, so nothing regresses there.
305
335
 
336
+ ### Command Code
337
+
338
+ Command Code's Provider API serves models on three wires: Claude models answer on
339
+ `/messages`, models that advertise `/responses` answer there, and the rest use
340
+ `/chat/completions`. A hand-written `models.json` entry declares one API family for the
341
+ whole provider, so it cannot route each model. Use the maintained
342
+ [`pi-commandcode-provider`](https://www.npmjs.com/package/pi-commandcode-provider)
343
+ package: it registers the `commandcode` provider, routes each model from
344
+ `supported_endpoints`, and normalises Command Code's reasoning deltas.
345
+
346
+ After installing it, migrate every saved reference without hand-editing JSON:
347
+
348
+ ```text
349
+ /profile retarget-provider command-code commandcode # dry run
350
+ /profile retarget-provider command-code commandcode --apply # writes profiles.json + roles.json
351
+ ```
352
+
353
+ The command rewrites only `{ provider, id }` model refs, preserves every other key,
354
+ and creates a `.bak` beside each file it changes. A malformed file blocks both writes.
355
+ Verify the new provider in `/model` before deleting the old static entry. The Pi Desktop
356
+ doctor prints the same recommendation when it sees a single-wire Command Code provider.
357
+
306
358
  ### Configuration
307
359
 
308
360
  Settings live under `workflow.reviewMode` in `~/.pi/agent/settings.json` (global) or `<cwd>/.pi/settings.json` (project). Project values win field-by-field over global. Writing them preserves every other key in the file.
309
361
 
362
+ ## Folder and read permission
363
+
364
+ **One shared floor.** `workflow.folderPolicy` decides which folders read, write and commit may touch. `mode: "repo"` (default) is the nearest `.git` ancestor (fallback: the session cwd); `cwd` is the exact working directory; `parent` is the parent of that base (siblings); `any` disables containment. `outside: "ask"` prompts once per turn (headless fails closed); `outside: "block"` refuses silently, and an unresolvable write/commit target is refused without a dialog (the read gate keeps its own `readPolicy.shell` opt-in). A policy can only be **stricter** than this floor — `writePolicy: "allow"` cannot write outside it. Both Default and Build respect the floor. For writes it asks only for a *proven* outside target, a sensitive file, or an explicit path that cannot be resolved; a command whose writes merely cannot be located is not treated as outside (see “Not a sandbox”). Windows Git-Bash/Cygwin/WSL spellings (`/c/...`, `/cygdrive/c/...`, `/mnt/c/...`) and `file://` paths are translated to native paths before matching; a folder grant covers the first folder outside the base (the outside project), while a file sitting directly in the common parent is granted as that file only. Read, write and commit dialogs list the base/current folder and every requested target path; a prompt caused by the floor is titled **Folder policy 'ask'**, not “Write policy”.
365
+
366
+ **Sensitive files are always blocked.** The default deny list covers `.env*` (templates such as `.env.example` are exempt by default), `.npmrc`/`.netrc`/`.git-credentials`, `auth.json`, `credentials*`, `secrets.*`, SSH/GPG/cloud stores, key/cert stores, `.git/config`, `.tfstate`/`.tfvars` and keychains. A folder grant never re-enables one; add a manual `folderPolicy.sensitiveExemptions` entry only when a template/fixture is genuinely needed. `/folder-policy sensitive` lists the effective rules.
367
+
368
+ **Read gate.** `read`, `ls`, `grep`, `find`, pi-lens read tools, `explore` cwd/tasks, local `fetch_content`/`browser_*` file URLs and balanced shell reads (`cat`, `rg`, `git show/-C`, redirections, `cp`/`tar` sources) are path-gated. `grep`/`find`/`ls` output, sensitive git diff sections and truncated `bash` full-output paths are redacted. `readPolicy.shell: "strict"` also asks for inline/interpreter commands that cannot be statically verified.
369
+
370
+ **Grants.** “Always allow” writes to the capability's `allowedPaths` in project settings by default (`folderPolicy.grantScope: "project"`); the project must be trusted. Existing global write grants remain honoured. “Always allow” cannot grant a filesystem root/home from a dialog — use `/folder-policy any` deliberately.
371
+
372
+ **Not a sandbox.** Arbitrary code (`node -e`, `python -c`, package scripts), renames of sensitive files and git history are documented residuals. A shell command whose writes cannot be located — `npm run build`, `node script`, `npx`, a `sed`/`awk` program, `powershell`, or a path-less tool in `mutatingTools` — is **not** treated as an outside write: under `writePolicy: "allow"` (and in Build) it runs without a dialog. That is deliberate: the analyzer cannot see inside project code, and asking made ordinary in-repo builds and tests look like a permission violation. Use `writePolicy: "ask"` for a prompt on every mutating call, or an OS container/VM for a hard boundary. The Review Mode reviewer gets guarded built-in `read`/`grep`/`find`/`ls` tools even though its session has no ambient extensions; `review_bash` uses the same read guard. Spawned subagents receive locked roots and cannot expand via a sibling project's settings.
373
+
374
+ **Migration.** The default `folderPolicy.mode: "repo"` + `outside: "ask"` also constrains writes/commits. Set `folderPolicy.mode: "any"` to restore the old “write anywhere” behaviour (sensitive files remain blocked); this now also disables the unresolvable-target asks. Use `cwd`/`parent` to keep a narrower floor.
375
+
310
376
  ## Commit policy
311
377
 
312
378
  By default the agent **cannot touch your git history**: the tool gate deterministically blocks `git add`, `git commit` and `git push` — including compound commands (`git add -A && git commit -m x`), global-option forms (`git -C path add`) and `powershell` wrappers. You commit manually, or run `/commit` for an AI-summarized, confirmation-gated commit. Read-only git (`status`, `log`, `diff`, `branch -a`, `config --get`, …) stays free, and plan mode keeps its stricter read-only rules regardless.
@@ -330,6 +396,8 @@ The default exists because prompt-only rules are soft: models habitually commit
330
396
 
331
397
  The status line always shows the active policy (`▶ build 2/5 · commits:never`), so you can see at a glance what the agent may do to history.
332
398
 
399
+ **Outside repositories.** The shared folder floor also gates the repository root. Inside the current project, only the commit policy above decides; a command that targets a repository outside the permitted folders (`git -C ../other ...`, or a worktree/GIT_DIR outside) opens a separate **Folder policy 'ask'** dialog, or is refused under `outside: "block"`. The two prompts are deliberately labelled apart, and the model-facing reason names the folder policy when that is what refused it.
400
+
333
401
  ### `/commit` under any policy
334
402
 
335
403
  `/commit` works under `never` and `allow`-during-a-plan because it is the user's own request. The flow is multi-turn (read the staged diff → confirm the message in a questionnaire → commit), so the exemption is **armed at the user's confirmation**, not when the flow starts:
@@ -354,12 +422,12 @@ Known limits (accepted, same trade-off OpenCode/Claude Code make): a configured
354
422
 
355
423
  The extension has three modes — **Plan → Build → Default**. Plan mode is read-only and Build mode is unrestricted *by design* (it is the approved-execution path), but **Default mode was also completely unrestricted**: the tool gate has no role to apply there, so the agent edits your files without asking, and it does so inconsistently — sometimes it asks for clarification, sometimes it goes straight to `edit`.
356
424
 
357
- `workflow.writePolicy` closes that gap. It is a **user-controlled permission**, enforced where the tools execute (not in prose — prompt rules are soft and get ignored), and it applies to **Default mode only**:
425
+ `workflow.writePolicy` closes that gap. It is a **user-controlled permission**, enforced where the tools execute (not in prose — prompt rules are soft and get ignored). The write **mode** applies to **Default mode only**; the shared folder floor is separate and applies in every mode, including Build — but it only asks for a proven outside target, a sensitive file, or an unresolvable explicit path. Build never inherits `ask`/`never`.
358
426
 
359
427
  | Mode | Meaning |
360
428
  | --- | --- |
361
429
  | `allow` (default) | The agent writes files freely — today's behaviour, and the shipped default so existing installs are unaffected |
362
- | `ask` | Writes pop a dialog: **Allow once (this turn)** / **Always allow …** (*this path*, *these paths*, *this command*, *these commands*) / **Deny** / **Deny with instructions…**. One dialog covers a whole turn and lists the writes it covers; a permanent grant is remembered, so the same target stops asking |
430
+ | `ask` | Writes pop a dialog: **Allow once (this turn)** / **Always allow …** (*this path*, *these paths*, *this folder*, *these folders*, *this command*, *these commands*) / **Deny** / **Deny with instructions…**. One dialog covers a whole turn and lists the writes it covers; a permanent grant is remembered, so the same target stops asking. A prompt caused by the folder floor is titled **Folder policy 'ask'** and shows the base folder; a write-mode prompt is **Write policy 'ask'** |
363
431
  | `never` | The agent may not modify your files (`.pi/plans/` excepted). You apply the changes, or the user switches to `/build` for the approved-execution path |
364
432
 
365
433
  The status line always shows the active permission (`◦ commits:never · writes:ask`), so you can see at a glance what the agent may touch.
@@ -368,17 +436,17 @@ The status line always shows the active permission (`◦ commits:never · writes
368
436
 
369
437
  - The `edit` and `write` tools.
370
438
  - `bash` commands that may modify files. A command is treated as a write when it is **not provably read-only**: a file redirect (`>`, `>>`), a heredoc or command substitution, a command outside the read-only/verify allowlist (`rm`, `mv`, `cp`, `sed`, `awk`, `npm install`, `git checkout`, interpreters, unknown binaries …), or a write flag on an otherwise-safe command (`eslint --fix`). Read-only commands (`ls`, `cat`, `rg`, `git status`, `npm test`, `tsc --noEmit`) and harmless redirections (`2>/dev/null`, `2>&1`) stay free. Only an **unquoted** redirect or expansion counts: `rg -n 'a > b' docs` and `rg -n "rm -rf" src` are read-only, so `ask` never prompts for them.
371
- - **`sed` and `awk` are gated as potential writes whatever their flags** — their programs are code (GNU sed's `e` command runs a shell command and its `w` command writes a file; awk can `system()` or redirect), so a plain `sed -n '…p' file` asks too. To inspect a file without a prompt use `rg` / `head` / `tail` / `cut` / `column` / `jq`, or the `read` tool — or pick **Always allow this command** to grant that one command permanently.
439
+ - **`sed` and `awk` are classified as potential writes whatever their flags** — their programs are code (GNU sed's `e` command runs a shell command and its `w` command writes a file; awk can `system()` or redirect), so a plain `sed -n '…p' file` is a *mutating* call. With `writePolicy: ask` it therefore prompts (use `rg` / `head` / `tail` / `cut` / `column` / `jq` or the `read` tool to avoid that); under `allow` and in Build it runs without a dialog, because the folder floor only asks for a *located* outside target.
372
440
  - `powershell` is always treated as a write — it cannot be classified by the POSIX allowlist, so it fails closed.
373
441
 
374
442
  `git add` / `git commit` / `git push` are **not** treated as file writes: they touch the index and the object store, not your working tree, and the independent [commit policy](#commit-policy) already governs them.
375
443
 
376
444
  ### Scope and carve-outs
377
445
 
378
- - **Default mode only.** Plan mode keeps its stricter read-only rules (writes only to `.pi/plans/`), and Build mode stays the deliberate "go ahead" escape hatch.
446
+ - **Default mode only for the write mode.** Plan mode keeps its stricter read-only rules (writes only to `.pi/plans/`), and Build stays the execution escape hatch: it never inherits `writePolicy: ask/never`. The shared folder floor still applies in Build and Default (outside targets and sensitive files).
379
447
  - **Subagents are unaffected.** A spawned child carries `PI_WORKFLOW_ROLE` and is already read-only through the role gate — and it has no UI to answer a prompt with.
380
448
  - **`.pi/plans/` writes stay allowed** in every mode, so a session that leaves Plan mode mid-flow is not stranded.
381
- - **Third-party write tools need opting in.** `ast_grep_replace` (pi-lens) rewrites files, and the `office_*` tools (pi-office-protocol) write documents; neither is covered by the built-in scope. List them in `mutatingTools` to gate them (see below).
449
+ - **Third-party write tools need opting in.** `ast_grep_replace` (pi-lens) rewrites files, and the `office_*` tools (pi-office-protocol) write documents; neither is covered by the built-in scope. List them in `mutatingTools` to gate them (see below). A path-less mutating tool follows the write mode: no prompt under `allow`/Build, prompted under `ask`.
382
450
 
383
451
  ```text
384
452
  /write-policy # status: mode, which scope supplied it, the allowlists
@@ -485,6 +553,12 @@ Review Mode settings live under `workflow.reviewMode`:
485
553
  | `passes` | `2` | `1` = review+rewrite only; `2` = review, then self-verify |
486
554
  | `verify` | `true` | Run the self-verification pass |
487
555
  | `fallbackOnError` | `"skip"` | `skip` hands over the author's plan on any failure (`block` is reserved) |
556
+ | `providerRecovery` | `"fallback"` | After a recoverable provider cut, try the fallback chain instead of failing the round immediately. `"off"` restores the single-model behaviour |
557
+ | `providerRetries` | `1` | SDK retries per reviewer model before the chain advances (0–2) |
558
+ | `fallbackModel` | unset | Optional explicit first fallback `{ provider, id, thinking }`; the automatic chain still follows it |
559
+ | `chunkedSubmit` | `"auto"` | Submit long rewrites through `review_plan_begin`/`review_plan_chunk`/`review_plan_finalize` instead of one oversized response. `"off"` keeps the one-shot `review_submit_plan` |
560
+ | `chunkChars` | `6000` | Max characters per chunk; also sets the auto threshold (`chunkChars × 2` plan characters). `0` disables chunked submission |
561
+ | `recoveryBudgetMs` | `0` | Overall time budget for the provider/fallback attempts. `0` uses the round deadline |
488
562
  | `exploreBudget` | `1` | Max `review_explore` subagents per round (cost ceiling). `0` whenever the Planner manifest already carries `explore` recon — the reviewer then verifies instead of re-exploring |
489
563
  | `timeoutMs` | `300000` | Wall-clock cap for a whole round, run included (5 minutes). `0` = unlimited (explicit opt-out); a pass then ends on `review_pass_done`, run settle, abort or error. A silent reviewer is still caught immediately by the settle race. `/review-mode status` prints the value and the settings file that set it, and `/review-mode timeout <seconds\|unlimited>` changes it without editing JSON |
490
564
  | `contextChars` | `15000` | Max chars of shared Plan Mode context in the pass-1 prompt (diet default; the reviewer re-reads truncated parts with `read`/`grep`) |
@@ -542,7 +616,7 @@ Write Permission settings live under `workflow.writePolicy` (same global/project
542
616
 
543
617
  ## How it works
544
618
 
545
- See `index.ts`, `checkpoint.ts` (shadow bare-repo), `commit-policy.ts` (commit policy config, coercion, the `/commit` prompt and write — no runtime imports, unit-testable), `questionnaire-layout.ts` (pure questionnaire viewport/scroll geometry), `questionnaire-view.ts` (the bounded bottom-sheet questionnaire view), `edit-policy.ts` (write-policy config, coercion, path/command matching, the decision table, and the guided-denial helpers — no runtime imports, unit-testable), `permissions.ts` (shell-command mutation classification), `utils.ts` (safe-command gating, todo/plan extraction, plan hashing), `roles.ts` (role registry, one model per role), `profiles.ts` (profile registry, the model overlay and the `workflow.defaultProfile` setting — no Pi runtime needed, unit-testable), `picker.ts` (the fuzzy-filterable picker primitives), and the Review Mode modules: `review.ts` (config, prompt, state, transcript, child session — no runtime imports, so it is unit-testable), `review-providers.ts` (parent→reviewer provider seeding and the provider-agnostic auth preflight), `review-tools.ts` (the reviewer's tool set), `review-runtime.ts` (round orchestration), `review-ui.ts` (the main-transcript entries: line buffer, formatters, renderers). Hot-reload via `/reload` when placed in `~/.pi/agent/extensions/workflow/` or loaded via `pi.extensions` manifest.
619
+ See `index.ts`, `checkpoint.ts` (shadow bare-repo), `commit-policy.ts` (commit policy config, coercion, the `/commit` prompt and write — no runtime imports, unit-testable), `questionnaire-layout.ts` (pure questionnaire viewport/scroll geometry), `questionnaire-view.ts` (the bounded bottom-sheet questionnaire view), `edit-policy.ts` (write-policy config, coercion, path/command matching, the decision table, and the guided-denial helpers — no runtime imports, unit-testable), `permissions.ts` (shell-command mutation classification), `shell-paths.ts` (path extraction for the shared floor), `folder-policy.ts` (the shared containment floor and sensitive-file rules), `read-policy.ts` / `read-policy-tools.ts` / `read-policy-redact.ts` (the read gate, tool classification and output redaction), `utils.ts` (safe-command gating, todo/plan extraction, plan hashing), `todo-panel.ts` (pure todo-panel state matrix, list identity, summary and row rendering), `roles.ts` (role registry, one model per role), `profiles.ts` (profile registry, the model overlay and the `workflow.defaultProfile` setting — no Pi runtime needed, unit-testable), `provider-retarget.ts` (guarded, backed-up `provider` ref migration for profiles/roles — pure document rewrite plus a two-phase file apply), `picker.ts` (the fuzzy-filterable picker primitives), and the Review Mode modules: `review.ts` (config, prompt, state, transcript, child session — no runtime imports, so it is unit-testable), `review-providers.ts` (parent→reviewer provider seeding and the provider-agnostic auth preflight), `review-tools.ts` (the reviewer's tool set), `review-runtime.ts` (round orchestration), `review-ui.ts` (the main-transcript entries: line buffer, formatters, renderers), `review-handoff.ts` (pure pending-gate correlation, supersede and reload-recovery decisions). Hot-reload via `/reload` when placed in `~/.pi/agent/extensions/workflow/` or loaded via `pi.extensions` manifest.
546
620
 
547
621
  ### Todo protocol (industry-aligned)
548
622
 
@@ -550,7 +624,8 @@ See `index.ts`, `checkpoint.ts` (shadow bare-repo), `commit-policy.ts` (commit p
550
624
  - **States**: `pending` → `in_progress` → `completed` (+ `cancelled`). Exactly one `in_progress` at a time (the harness normalizes extras). `completed` is kept as a derived legacy field for old sessions/external readers.
551
625
  - **Update**: `workflow_todo {action:"update", todos:[{ref?, text?, status?}]}` replaces the whole list — TodoWrite/todowrite/update_plan semantics. A `ref` is resolved by canonical ref, then normalized text, then leniently by plan label / group-qualified ref / unique text; a ref that had to be resolved leniently (or matched nothing and was added) is reported under `Warnings:` in the tool result. `done`/`pending` are idempotent single-item sets; `toggle` is a convenience flip; `add` appends an ad-hoc item; `sync` re-extracts the plan file, matches steps by exact text then by plan label (so model-rewritten wording reunites instead of duplicating), **preserves agent-added items** (reported as `kept N agent-added`) and never un-completes (status rank merge); `clear` empties.
552
626
  - **Reminder cadence** (Claude Code-style throttle): a hidden `[TODO LIST]` context block is injected only when `turnsSinceLastTodoWrite ≥ TURNS_SINCE_WRITE` **and** `turnsSinceLastReminder ≥ TURNS_BETWEEN_REMINDERS` (`=3`). Unresolved `[DONE:…]` refs from the previous turn bypass the throttle once so the model learns the right numbers. A one-line `[TODO …]` footer is appended to the first successful `edit`/`write`/`bash` result of each turn.
553
- - **Stale notice (no forced turns)**: if a run mutated files but recorded no todo progress, the user gets one `⚠` status marker + a `ctx.ui.notify` (30 s dedupe); plan-file `- [x]` ticks are reconciled at `agent_end` in all modes, skipped when the plan content is unchanged (path+hash guard). No auto-completion — the list is never marked done based on intent. Legacy lists that already contain duplicate rows are not auto-merged; run `/todos clear` (or approve a new plan) to reset.
627
+ - **Stale notice (no forced turns)**: if a run mutated files but recorded no todo progress, the user gets one `⚠` status marker + a `ctx.ui.notify` (30 s dedupe); plan-file `- [x]` ticks are reconciled at `agent_end` in all modes, skipped when the plan content is unchanged (path+hash guard). No auto-completion — the list is never marked done based on intent. Legacy lists that already contain duplicate rows are not auto-merged; run `/todos clear` (asks first in an interactive session; headless clears immediately) or approve a new plan to reset.
628
+ - **Panel lifecycle**: the pinned row is expanded while work is open, collapses to one summary line (`☑ Todos N/N · all done`) the moment every step is completed or cancelled, and hides at the next user prompt. `Ctrl+Alt+O` toggles expanded/collapsed (hidden → expanded); `/todos expand`, `/todos collapse`, `/todos hide` and `/todos auto` control it explicitly, and the choice is remembered for that list across reloads and rewinds. Adding, removing or renaming a step re-expands; status-only changes keep the choice. The transcript still records every `workflow_todo` result and the Build-complete card, so nothing is lost when the pinned panel is out of the way.
554
629
 
555
630
  ## Troubleshooting
556
631
 
@@ -574,11 +649,20 @@ The warning reads `review skipped (the reviewer's provider failed after 3 retry
574
649
 
575
650
  Earlier versions mislabeled this as `the reviewer finished without submitting a plan — it has no write path…`; since 0.3.3 the real provider error is captured from the reviewer session and reported here, in the pane, and via `/review-status` (the old message now appears only when the reviewer genuinely settled without calling `review_submit_plan`).
576
651
 
652
+ **If the text is `This operation was aborted`, the provider was not at fault.** That is Node's default abort reason: Review Mode aborted the reviewer's closing tail after `review_pass_done` (or on pause/teardown), and the SDK's `lazyStream` setup path recorded the cancellation as a `stopReason: "error"` assistant message. Before the abort classifier shipped, that was displayed as `reviewer run ended on a provider error: This operation was aborted` and could land in `/review-status` as a `provider:` line. It consumed no retry, wrote nothing, and is not actionable — it no longer appears in the pane or the status.
653
+
654
+ If the text is specifically `Stream ended without finish_reason`, the gateway closed a long
655
+ SSE response before Pi saw a terminal reason. Pi treats that as a fatal provider error, and
656
+ the retry repeats the same long request. Lowering the thinking level may shorten it, but it
657
+ is not a reliable fix. On Command Code, use the maintained provider and retarget the saved
658
+ references (see [Command Code](#command-code)); then verify the new model in `/model` before
659
+ removing the old static entry.
660
+
577
661
  What to do:
578
662
 
579
- 1. **Check the provider** — the truncated message is the provider's own reply (rate limit, quota, overloaded, auth). Retry the review with `/review` once the provider recovers; a review that dies this way can simply be re-run.
663
+ 1. **Check the provider** — the truncated message is the provider's own reply (rate limit, quota, overloaded, auth, or a mid-stream cut). Retry the review with `/review` once the provider recovers; a review that dies this way can simply be re-run.
580
664
  2. **A long review shares one API key with the parent** — planner + reviewer often hit the same key, so a long review can exhaust rate limits. Stagger heavy Plan Mode work or raise the provider's quota.
581
- 3. **Switch the reviewer model** if the failures recur — `/role set reviewer <provider/model>` (see [Review Mode](#review-mode)); pick a model whose provider is currently healthy.
665
+ 3. **Switch the reviewer model** if the failures recur — `/profile use <name>` or `/role set reviewer <provider/model>` (see [Review Mode](#review-mode)); pick a model whose provider is currently healthy.
582
666
 
583
667
  ## Development
584
668
 
@@ -586,7 +670,7 @@ What to do:
586
670
  cd agent/extensions/workflow
587
671
  npm install
588
672
  npm run typecheck # tsc --noEmit (covers every source file, including the review modules)
589
- npm test # node --test utils.todo.test.ts permissions.test.ts shell-text.test.ts roles.test.ts profiles.test.ts thinking-levels.test.ts workflow-status.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts
673
+ npm test # node --test utils.todo.test.ts permissions.test.ts shell-text.test.ts roles.test.ts profiles.test.ts provider-retarget.test.ts thinking-levels.test.ts workflow-status.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts
590
674
  ```
591
675
 
592
676
  The write policy also has an end-to-end check that drives the real `tool_call` handler against the real `settings.json` (it temporarily writes your global settings and restores them afterwards):
package/commit-policy.ts CHANGED
@@ -24,7 +24,11 @@
24
24
  import * as fs from "node:fs";
25
25
  import * as path from "node:path";
26
26
  import type { ArgumentCompletion } from "./utils.ts";
27
- import { joinArgumentValue, suppressExactArgumentMatch } from "./utils.ts";
27
+ import {
28
+ joinArgumentValue,
29
+ suppressExactArgumentMatch,
30
+ translateForeignPath,
31
+ } from "./utils.ts";
28
32
 
29
33
  /** Pi's project config directory. Mirrors WORKFLOW_CONFIG_DIR without importing review.ts (which pulls provider modules). */
30
34
  export const COMMIT_CONFIG_DIR = ".pi";
@@ -40,10 +44,13 @@ export type CommitPolicyMode = "never" | "ask" | "allow";
40
44
 
41
45
  export interface CommitPolicyConfig {
42
46
  mode: CommitPolicyMode;
47
+ /** Repository/worktree roots that may be committed outside the folder base. */
48
+ allowedPaths: string[];
43
49
  }
44
50
 
45
51
  export const DEFAULT_COMMIT_POLICY_CONFIG: Readonly<CommitPolicyConfig> = {
46
52
  mode: "never",
53
+ allowedPaths: [],
47
54
  };
48
55
 
49
56
  const COMMIT_POLICY_MODES: readonly CommitPolicyMode[] = [
@@ -71,6 +78,24 @@ function coerceMode(value: unknown): CommitPolicyMode | undefined {
71
78
  : undefined;
72
79
  }
73
80
 
81
+ function coerceStringList(value: unknown): string[] | undefined {
82
+ if (!Array.isArray(value)) return undefined;
83
+ const out: string[] = [];
84
+ for (const item of value) {
85
+ if (typeof item !== "string") continue;
86
+ const trimmed = item.trim();
87
+ if (!trimmed || out.includes(trimmed)) continue;
88
+ out.push(trimmed);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function unionStrings(a: readonly string[], b: readonly string[]): string[] {
94
+ const out = [...a];
95
+ for (const item of b) if (!out.includes(item)) out.push(item);
96
+ return out;
97
+ }
98
+
74
99
  /**
75
100
  * Coerce an untrusted settings fragment into a complete CommitPolicyConfig.
76
101
  * Accepts an object (`{ mode: "never" }`) or a bare mode string shorthand.
@@ -83,13 +108,19 @@ export function coerceCommitPolicyConfig(
83
108
  ): CommitPolicyConfig {
84
109
  if (typeof raw === "string") {
85
110
  const mode = coerceMode(raw);
86
- return mode ? { mode } : { ...base };
111
+ return mode ? { ...base, mode } : { ...base };
87
112
  }
88
113
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
89
114
  return { ...base };
90
115
  }
91
116
  const mode = coerceMode((raw as JsonObject).mode);
92
- return { mode: mode ?? base.mode };
117
+ return {
118
+ mode: mode ?? base.mode,
119
+ allowedPaths:
120
+ coerceStringList((raw as JsonObject).allowedPaths) ?? [
121
+ ...base.allowedPaths,
122
+ ],
123
+ };
93
124
  }
94
125
 
95
126
  /** Extract the `workflow.commitPolicy` fragment from a raw settings object. */
@@ -116,7 +147,14 @@ export function mergeCommitPolicyConfig(
116
147
  projectRaw: unknown,
117
148
  ): CommitPolicyConfig {
118
149
  const fromGlobal = coerceCommitPolicyConfig(globalRaw);
119
- return coerceCommitPolicyConfig(projectRaw, fromGlobal);
150
+ const fromProject = coerceCommitPolicyConfig(projectRaw, fromGlobal);
151
+ return {
152
+ mode: fromProject.mode,
153
+ allowedPaths: unionStrings(
154
+ fromGlobal.allowedPaths,
155
+ fromProject.allowedPaths,
156
+ ),
157
+ };
120
158
  }
121
159
 
122
160
  export interface CommitConfigPaths {
@@ -132,9 +170,11 @@ export function commitConfigPaths(
132
170
  cwd: string,
133
171
  agentDir: string,
134
172
  ): CommitConfigPaths {
173
+ const nativeCwd = translateForeignPath(cwd).path || cwd;
174
+ const nativeAgentDir = translateForeignPath(agentDir).path || agentDir;
135
175
  return {
136
- global: path.join(agentDir, "settings.json"),
137
- project: path.join(cwd, COMMIT_CONFIG_DIR, "settings.json"),
176
+ global: path.join(nativeAgentDir, "settings.json"),
177
+ project: path.join(nativeCwd, COMMIT_CONFIG_DIR, "settings.json"),
138
178
  };
139
179
  }
140
180
 
@@ -192,19 +232,22 @@ export interface ResolvedCommitPolicy {
192
232
  export function readCommitPolicyConfig(
193
233
  cwd: string,
194
234
  agentDir: string,
235
+ options: { trusted?: boolean } = {},
195
236
  ): ResolvedCommitPolicy {
196
237
  const paths = commitConfigPaths(cwd, agentDir);
197
238
  const globalSettings = readJsonSafe(paths.global);
198
239
  const projectSettings = readJsonSafe(paths.project);
199
240
  const globalRaw = extractCommitPolicyRaw(globalSettings);
200
241
  const projectRaw = extractCommitPolicyRaw(projectSettings);
201
- const config = mergeCommitPolicyConfig(globalRaw, projectRaw);
242
+ const trusted = options.trusted === true;
243
+ const effectiveProjectRaw = trusted ? projectRaw : undefined;
244
+ const config = mergeCommitPolicyConfig(globalRaw, effectiveProjectRaw);
202
245
  return {
203
246
  config,
204
247
  sources: {
205
248
  global: globalRaw !== undefined,
206
249
  project: projectRaw !== undefined,
207
- mode: modeSourceOf(globalRaw, projectRaw),
250
+ mode: modeSourceOf(globalRaw, effectiveProjectRaw),
208
251
  },
209
252
  };
210
253
  }
@@ -476,6 +519,10 @@ export interface CommitGateInput {
476
519
  hasTodos: boolean;
477
520
  auth?: CommitAuthorizationState | null;
478
521
  now: number;
522
+ /** Shared folder floor verdict (default allow for legacy callers). */
523
+ folder?: "allow" | "ask" | "block";
524
+ /** A sensitive path would be added/committed. */
525
+ sensitive?: boolean;
479
526
  }
480
527
 
481
528
  /**
@@ -486,12 +533,23 @@ export interface CommitGateInput {
486
533
  */
487
534
  export function decideCommitGate(input: CommitGateInput): CommitGateDecision {
488
535
  const { policy, hit, hasTodos } = input;
536
+ if (input.sensitive) return "block";
537
+ if (input.folder === "block") return "block";
538
+ if (
539
+ hit === "commit" &&
540
+ isAuthorizationActive(input.auth, input.now) &&
541
+ input.folder !== "ask"
542
+ ) {
543
+ return "allow";
544
+ }
545
+ if (policy === "never") return "block";
546
+ if (input.folder === "ask") return "ask";
489
547
  if (policy === "allow" && !hasTodos) return "allow";
490
548
  if (policy === "ask" && hit === "add") return "allow";
491
549
  if (hit === "commit" && isAuthorizationActive(input.auth, input.now)) {
492
550
  return "allow";
493
551
  }
494
- if (policy === "never" || policy === "allow") return "block";
552
+ if (policy === "allow") return "block";
495
553
  return "ask";
496
554
  }
497
555
 
@@ -516,7 +574,13 @@ export function writeCommitPolicyConfig(
516
574
  ? { ...(wfExisting as JsonObject) }
517
575
  : {};
518
576
  const current = coerceCommitPolicyConfig(wf[COMMIT_POLICY_KEY]);
519
- const next = coerceCommitPolicyConfig({ ...current, ...patch }, current);
577
+ const next: CommitPolicyConfig = {
578
+ mode: coerceMode(patch.mode) ?? current.mode,
579
+ allowedPaths: unionStrings(
580
+ current.allowedPaths,
581
+ coerceStringList(patch.allowedPaths) ?? [],
582
+ ),
583
+ };
520
584
  wf[COMMIT_POLICY_KEY] = next;
521
585
  root[WORKFLOW_SETTINGS_KEY] = wf;
522
586
  fs.mkdirSync(path.dirname(fp), { recursive: true });