@dieulc/workflow 0.10.0 → 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,7 +33,7 @@ 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
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]` |
@@ -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
 
@@ -240,16 +242,17 @@ Mode changes are reported by Pi's **footer status** (`⏸ plan` / `▶ build`, p
240
242
 
241
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.
242
244
 
243
- **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.
244
246
 
245
247
  #### Where your next message goes
246
248
 
247
- 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:
248
250
 
249
251
  - **Plan Mode** → the message goes to the plan agent.
250
- - **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.
251
252
  - **Neither** (including while the plan sits in Plannotator) → the message goes to the plan agent, so you can still refine the plan.
252
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
+
253
256
  Delivery to the reviewer is best-effort: if it fails, the message stays in the plan-agent chat rather than being swallowed.
254
257
 
255
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.
@@ -268,7 +271,7 @@ A paused review is in-memory and belongs to the cycle that recorded it: it canno
268
271
 
269
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.
270
273
 
271
- - **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.
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.
272
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.
273
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.
274
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).
@@ -356,6 +359,20 @@ doctor prints the same recommendation when it sees a single-wire Command Code pr
356
359
 
357
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.
358
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
+
359
376
  ## Commit policy
360
377
 
361
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.
@@ -379,6 +396,8 @@ The default exists because prompt-only rules are soft: models habitually commit
379
396
 
380
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.
381
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
+
382
401
  ### `/commit` under any policy
383
402
 
384
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:
@@ -403,12 +422,12 @@ Known limits (accepted, same trade-off OpenCode/Claude Code make): a configured
403
422
 
404
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`.
405
424
 
406
- `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`.
407
426
 
408
427
  | Mode | Meaning |
409
428
  | --- | --- |
410
429
  | `allow` (default) | The agent writes files freely — today's behaviour, and the shipped default so existing installs are unaffected |
411
- | `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'** |
412
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 |
413
432
 
414
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.
@@ -417,17 +436,17 @@ The status line always shows the active permission (`◦ commits:never · writes
417
436
 
418
437
  - The `edit` and `write` tools.
419
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.
420
- - **`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.
421
440
  - `powershell` is always treated as a write — it cannot be classified by the POSIX allowlist, so it fails closed.
422
441
 
423
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.
424
443
 
425
444
  ### Scope and carve-outs
426
445
 
427
- - **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).
428
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.
429
448
  - **`.pi/plans/` writes stay allowed** in every mode, so a session that leaves Plan mode mid-flow is not stranded.
430
- - **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`.
431
450
 
432
451
  ```text
433
452
  /write-policy # status: mode, which scope supplied it, the allowlists
@@ -597,7 +616,7 @@ Write Permission settings live under `workflow.writePolicy` (same global/project
597
616
 
598
617
  ## How it works
599
618
 
600
- 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), `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.
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.
601
620
 
602
621
  ### Todo protocol (industry-aligned)
603
622
 
@@ -605,7 +624,8 @@ See `index.ts`, `checkpoint.ts` (shadow bare-repo), `commit-policy.ts` (commit p
605
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.
606
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.
607
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.
608
- - **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.
609
629
 
610
630
  ## Troubleshooting
611
631
 
@@ -629,6 +649,8 @@ The warning reads `review skipped (the reviewer's provider failed after 3 retry
629
649
 
630
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`).
631
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
+
632
654
  If the text is specifically `Stream ended without finish_reason`, the gateway closed a long
633
655
  SSE response before Pi saw a terminal reason. Pi treats that as a fatal provider error, and
634
656
  the retry repeats the same long request. Lowering the thinking level may shorten it, but it
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 });