@dieulc/workflow 0.9.1 → 0.10.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
@@ -36,7 +36,7 @@ Manage it with `pi list`, `pi update --extensions`, `pi remove npm:@dieulc/workf
36
36
  | `/todos` | Show todo list |
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,6 +45,7 @@ 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
+ | `/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 |
48
49
 
49
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).
50
51
 
@@ -144,7 +145,10 @@ Plan Mode and Review Mode share one role-based command policy (`permissions.ts`)
144
145
  | `read`, `grep`, `find`, `ls` | Inspect the repository |
145
146
  | `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` |
146
147
  | `review_explore` | Read-only reconnaissance subagents on the **explorer** role's model, capped by `exploreBudget` per round |
147
- | `review_submit_plan` | The **only** write path: validates and saves the revised plan to Plan Mode's own `.pi/plans/<UTC-date>-<slug>.md` |
148
+ | `review_plan_begin` | Starts a **chunked** rewrite: resets the per-round assembler and states the per-chunk size limit |
149
+ | `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 |
150
+ | `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 |
151
+ | `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 |
148
152
  | `review_pass_done` | Ends a pass with a verdict (refused if nothing was submitted) |
149
153
 
150
154
  The policy classifies commands into three classes and maps them to roles:
@@ -156,19 +160,41 @@ The policy classifies commands into three classes and maps them to roles:
156
160
  | explorer | read-only | none | `explore` / `review_explore` subagents |
157
161
  | builder | full (unrestricted) | all | Build Mode / normal sessions |
158
162
 
159
- - **read-only** — inspection only (`ls`, `cat`, `rg`, `git log/blame/ls-files/show-ref`, `npm run` / `yarn run` with no script argument (lists them), `docker logs`, `journalctl`, `base64`, `ss`, `netstat`, `lsof`, …). `sed`/`awk` are deliberately **not** read-only — their programs are code — so they are Builder-only.
163
+ - **read-only** — inspection only (`ls`, `cat`, `rg`, `git log/blame/ls-files/show-ref`, `npm run` / `yarn run` with no script argument (lists them), `docker logs`, `journalctl`, `base64`, `ss`, `netstat`, `lsof`, …). `sed`/`awk` are deliberately **not** read-only — their programs are code — so they are Builder-only. A **quoted word is data, not a command**: `rg -n "rm -rf" src` searches for a string and is read-only, because the destructive-keyword layer only matches in command position (or anywhere in a segment already classified `full`).
160
164
  - **verify** — the above plus `npm test`, `npm run lint|typecheck|check`, `eslint`, `tsc --noEmit`, `node --test`, `npm run`/`node --run` with an allowlisted script, `pytest`, `mypy`, `go vet`, `cargo clippy`, … Runners that rewrite (`--fix`, `--write`, `-u`/`-w`/`--update`, `--test-update-snapshots`, `--watch`) — including through a wrapper such as `npm test -- -u` — and arbitrary output-path flags (`eslint -o`, `jest --outputFile`) stay blocked, as do build commands (`npm run build`).
161
165
  - **full** — no gate (Builder and normal sessions only).
162
166
 
163
- Compound commands are checked segment-by-segment with quote awareness, so `git status && npm test` and `npm test 2>/dev/null | tail -50` work while `curl … | sh`, `$(…)`, backticks, heredocs, `sed`/`awk` and file redirects to anything other than `/dev/null` are refused. Refusals name the rule that failed (`explainCommandRefusal`), so a blocked command explains what to change. Explorer subagents run as separate `pi` processes and carry their role in `PI_WORKFLOW_ROLE`, so the same policy gates their `bash` too. The `powershell` tool is refused for every non-builder role, since its syntax cannot be classified by the POSIX allowlist.
167
+ Compound commands are checked segment-by-segment, and each check reads the view that can answer it (`shell-text.ts`): **operators** (`&`, `>`, `>>`, `<<`, `<(`, `>(`) count only when unquoted, so `rg -n 'a > b' docs` is a search while `rg -n 'a' f > out` is a redirect; **expansions** (`$(…)`, backticks, `${…}`) count outside single quotes only, so `rg -n '$(id)' f` is literal while `rg -n "$(id)" f` executes; **flags** are read from argv after quote removal, so `find . "-delete"` is still refused. `git status && npm test` and `npm test 2>/dev/null | tail -50` work, while `curl … | sh`, unquoted `$(…)`/backticks/heredocs, `sed`/`awk` and file redirects to anything other than `/dev/null` are refused. Refusals name the rule that failed (`explainCommandRefusal`), and `/permissions explain <command>` prints the same verdict without running anything. Explorer subagents run as separate `pi` processes and carry their role in `PI_WORKFLOW_ROLE`, so the same policy gates their `bash` too. The `powershell` tool is refused for every non-builder role, since its syntax cannot be classified by the POSIX allowlist.
164
168
 
165
169
  The built-in `bash`, `edit` and `write` tools are never given to the reviewer at all.
166
170
 
171
+ ### Chunked rewrite submission
172
+
173
+ A plan rewrite is the one response in the flow that can be longer than a provider gateway allows a
174
+ stream to live. Nothing is written until a submission succeeds, so a cut mid-response loses the
175
+ whole rewrite and burns the round. Chunking makes the rewrite survive that:
176
+
177
+ 1. `review_plan_begin` (optionally with `totalChunks`) resets the assembler.
178
+ 2. `review_plan_chunk` appends `index` 1, 2, 3, … — each at most `chunkChars` characters. A
179
+ rejected chunk (out of order, repeated, oversized, or cut before it arrived) is answered with
180
+ the index the assembler expects next, so the retry continues **without restarting**: already
181
+ accepted chunks are kept. Calling `review_plan_begin` again is the explicit "throw it away" move.
182
+ 3. `review_plan_finalize` validates completeness (`totalChunks`, when declared) and calls the same
183
+ submit funnel the one-shot path uses — `writePlan` stays the single writer and the review gate
184
+ is notified exactly once.
185
+
186
+ Pass 1 asks for the chunked protocol when `chunkChars > 0` **and** the plan is longer than
187
+ `chunkChars × 2`; shorter plans keep the one-shot `review_submit_plan`. `chunkedSubmit: "off"` or
188
+ `chunkChars: 0` disables chunking entirely (the runtime then refuses `review_plan_begin`). Pass 2
189
+ reads the assembled rewrite and its diff, so a chunked pass 1 feeds the self-verify pass unchanged.
190
+ On abort, teardown, a new round or a provider switch the assembler is cleared, so a partial rewrite
191
+ can never be stitched onto a different attempt.
192
+
167
193
  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.
168
194
 
169
195
  ### Two passes
170
196
 
171
- 1. **Review + rewrite** — audits the plan against the repository and the guidance, records findings, submits the revised plan.
197
+ 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).
172
198
  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.
173
199
 
174
200
  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.
@@ -226,7 +252,7 @@ Input follows the **active mode**, decided automatically — there is nothing to
226
252
 
227
253
  Delivery to the reviewer is best-effort: if it fails, the message stays in the plan-agent chat rather than being swallowed.
228
254
 
229
- **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. `/review-status` still reports the last round's result. While the review owns the turn, the footer also appends `🔒 modes locked`.
255
+ **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.
230
256
 
231
257
  ### Controlling a running review
232
258
 
@@ -234,8 +260,14 @@ The handoff **waits for the reviewer**: no browser prompt or inline choice rende
234
260
 
235
261
  A handoff opens **only when the current run writes or edits a `.pi/plans/` file while Plan mode is active**. A mid-run switch into Plan counts — the write after the switch is eligible. A plan written before the switch does not auto-handoff: the extension shows `Plan file ready: <path>. Run /review to audit it.` and leaves the plan for the manual command. Switching Build → Plan keeps the previous plan and todos as reference; a fresh prompt also supersedes any pending browser/inline handoff prompt, and a stale browser approval/denial from a previous plan is ignored.
236
262
 
263
+ **Review cycles.** The round budget (`rounds`) is per plan/feedback cycle, not per session. A fresh prompt, an explicit inline `Refine` or browser `Deny`, a different plan-file path, and entering Build all start a new cycle, so a second plan in the same session still gets reviewed with the default `rounds: 1`. Findings carry across a refinement of the same plan file and are cleared for a new plan file. A fresh prompt **abandons** a paused review: the old round is stopped, its open handoff gate settles with the author's plan, and the footer/lock clear. The old handoff cannot render or apply a plan after the new cycle starts.
264
+
265
+ A paused review is in-memory and belongs to the cycle that recorded it: it cannot keep `🔒 modes locked` alive or short-circuit the next plan's review after a new prompt.
266
+
237
267
  `/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/`.
238
268
 
269
+ **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
+
239
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.
240
272
  - **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.
241
273
  - `/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.
@@ -245,7 +277,7 @@ A handoff opens **only when the current run writes or edits a `.pi/plans/` file
245
277
  - `/review say <message>` talks to the in-flight reviewer.
246
278
  - `/review-status` shows phase, round, the snapshotted model, findings, any paused state, and the mode-switch lock with its allowed exits.
247
279
 
248
- Pausing is in-memory only: a reload starts fresh.
280
+ Pausing is in-memory only: a reload starts fresh. A pause also belongs to the current review cycle; a new prompt abandons it (and clears the footer/lock) rather than leaving a resumable round from the previous task.
249
281
 
250
282
  ### Isolation
251
283
 
@@ -298,6 +330,28 @@ Every bridged method is feature-detected: on a Pi version without a given access
298
330
 
299
331
  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.
300
332
 
333
+ ### Command Code
334
+
335
+ Command Code's Provider API serves models on three wires: Claude models answer on
336
+ `/messages`, models that advertise `/responses` answer there, and the rest use
337
+ `/chat/completions`. A hand-written `models.json` entry declares one API family for the
338
+ whole provider, so it cannot route each model. Use the maintained
339
+ [`pi-commandcode-provider`](https://www.npmjs.com/package/pi-commandcode-provider)
340
+ package: it registers the `commandcode` provider, routes each model from
341
+ `supported_endpoints`, and normalises Command Code's reasoning deltas.
342
+
343
+ After installing it, migrate every saved reference without hand-editing JSON:
344
+
345
+ ```text
346
+ /profile retarget-provider command-code commandcode # dry run
347
+ /profile retarget-provider command-code commandcode --apply # writes profiles.json + roles.json
348
+ ```
349
+
350
+ The command rewrites only `{ provider, id }` model refs, preserves every other key,
351
+ and creates a `.bak` beside each file it changes. A malformed file blocks both writes.
352
+ Verify the new provider in `/model` before deleting the old static entry. The Pi Desktop
353
+ doctor prints the same recommendation when it sees a single-wire Command Code provider.
354
+
301
355
  ### Configuration
302
356
 
303
357
  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.
@@ -362,7 +416,7 @@ The status line always shows the active permission (`◦ commits:never · writes
362
416
  ### What is gated
363
417
 
364
418
  - The `edit` and `write` tools.
365
- - `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.
419
+ - `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.
366
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.
367
421
  - `powershell` is always treated as a write — it cannot be classified by the POSIX allowlist, so it fails closed.
368
422
 
@@ -475,11 +529,17 @@ Review Mode settings live under `workflow.reviewMode`:
475
529
 
476
530
  | Key | Default | Meaning |
477
531
  | --- | --- | --- |
478
- | `enabled` | `false` | Master toggle. When false, every Review Mode code path is a no-op |
479
- | `rounds` | `1` | Max Plan↔Review rounds per user-feedback cycle. Reached the cap → the plan is handed over with an `## Unresolved findings` appendix |
532
+ | `enabled` | `false` | Automatic-review toggle. When false, the handoff review/prefetch/planner note are off; the manual `/review*` commands still work |
533
+ | `rounds` | `1` | Max Plan↔Review rounds per plan/feedback cycle. A fresh prompt, explicit refine/deny, or a different plan file starts a new cycle. Reached the cap → the plan is handed over with an `## Unresolved findings` appendix |
480
534
  | `passes` | `2` | `1` = review+rewrite only; `2` = review, then self-verify |
481
535
  | `verify` | `true` | Run the self-verification pass |
482
536
  | `fallbackOnError` | `"skip"` | `skip` hands over the author's plan on any failure (`block` is reserved) |
537
+ | `providerRecovery` | `"fallback"` | After a recoverable provider cut, try the fallback chain instead of failing the round immediately. `"off"` restores the single-model behaviour |
538
+ | `providerRetries` | `1` | SDK retries per reviewer model before the chain advances (0–2) |
539
+ | `fallbackModel` | unset | Optional explicit first fallback `{ provider, id, thinking }`; the automatic chain still follows it |
540
+ | `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` |
541
+ | `chunkChars` | `6000` | Max characters per chunk; also sets the auto threshold (`chunkChars × 2` plan characters). `0` disables chunked submission |
542
+ | `recoveryBudgetMs` | `0` | Overall time budget for the provider/fallback attempts. `0` uses the round deadline |
483
543
  | `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 |
484
544
  | `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 |
485
545
  | `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`) |
@@ -518,7 +578,7 @@ Write Permission settings live under `workflow.writePolicy` (same global/project
518
578
  | --- | --- | --- |
519
579
  | `mode` | `"allow"` | `"allow"` = the agent writes freely in Default mode; `"ask"` = every write prompts; `"never"` = writes are blocked (`.pi/plans/` excepted — you write, or the user switches to `/build`) |
520
580
  | `allowedPaths` | `[]` | Absolute paths the agent may write without being asked again (stored absolute, forward-slashed and lower-cased). An entry covers the file itself and everything under it on a directory boundary |
521
- | `allowedCommands` | `[]` | Command keys the agent may run without being asked again (the mutating shell segment, e.g. `rm -rf build`). A key is stored **normalised** — trimmed, whitespace collapsed, lower-cased — so a hand-written entry has to match that form |
581
+ | `allowedCommands` | `[]` | Command keys the agent may run without being asked again (the mutating shell segment, e.g. `rm -rf build`). A key is stored **normalised** — trimmed, whitespace collapsed, lower-cased — so a hand-written entry has to match that form. A grant covers exactly one command (plus longer forms of it on a word boundary), never a command's head or a pattern, so a different `sed -n '<range>' <file>` needs its own grant — prefer the `read` tool (`offset`/`limit`) or `head -n N \| tail -n +M`, which never prompt and need no grant |
522
582
  | `mutatingTools` | `[]` | Extra tool names to treat as file writes (e.g. `ast_grep_replace`, `office_excel_write_cells`). A grant for a tool **without** a path is stored in `allowedCommands` as `tool:<name>` (e.g. `tool:ast_grep_replace`) |
523
583
 
524
584
  ```json
@@ -537,7 +597,7 @@ Write Permission settings live under `workflow.writePolicy` (same global/project
537
597
 
538
598
  ## How it works
539
599
 
540
- 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.
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.
541
601
 
542
602
  ### Todo protocol (industry-aligned)
543
603
 
@@ -569,11 +629,18 @@ The warning reads `review skipped (the reviewer's provider failed after 3 retry
569
629
 
570
630
  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`).
571
631
 
632
+ If the text is specifically `Stream ended without finish_reason`, the gateway closed a long
633
+ SSE response before Pi saw a terminal reason. Pi treats that as a fatal provider error, and
634
+ the retry repeats the same long request. Lowering the thinking level may shorten it, but it
635
+ is not a reliable fix. On Command Code, use the maintained provider and retarget the saved
636
+ references (see [Command Code](#command-code)); then verify the new model in `/model` before
637
+ removing the old static entry.
638
+
572
639
  What to do:
573
640
 
574
- 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.
641
+ 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.
575
642
  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.
576
- 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.
643
+ 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.
577
644
 
578
645
  ## Development
579
646
 
@@ -581,7 +648,7 @@ What to do:
581
648
  cd agent/extensions/workflow
582
649
  npm install
583
650
  npm run typecheck # tsc --noEmit (covers every source file, including the review modules)
584
- npm test # node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts edit-policy.test.ts
651
+ 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
585
652
  ```
586
653
 
587
654
  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/edit-policy.ts CHANGED
@@ -39,14 +39,7 @@ import {
39
39
  WORKFLOW_SETTINGS_KEY,
40
40
  type JsonObject,
41
41
  } from "./commit-policy.ts";
42
- import {
43
- commandClassOf,
44
- forbiddenSyntaxReason,
45
- gitCommitPolicyHit,
46
- splitCommandSegments,
47
- stripHarmlessRedirects,
48
- DENIED_FLAG_RE,
49
- } from "./permissions.ts";
42
+ import { analyzeCommand } from "./permissions.ts";
50
43
 
51
44
  /** Nested key under WORKFLOW_SETTINGS_KEY. */
52
45
  export const WRITE_POLICY_KEY = "writePolicy";
@@ -408,67 +401,21 @@ export interface WriteIntent {
408
401
  /**
409
402
  * Classify a shell command as mutating or not.
410
403
  *
411
- * Per segment, in this order:
412
- * 1. forbidden shell syntax (file redirect, heredoc, command/process
413
- * substitution, background `&`) → mutating. Checked FIRST so a
414
- * redirect riding along with a git command (`git commit -m x > log`)
415
- * is still caught as a write.
416
- * 2. `git add` / `git commit` / `git push` → NOT mutating: they touch the
417
- * index and the object store, not the user's working-tree files, and
418
- * the independent commit policy already governs them.
419
- * 3. an unrestricted command class (`commandClassOf` → "full") → mutating.
420
- * This is the fail-closed default: anything not provably read-only
421
- * (sed/awk programs, interpreters, unknown binaries) may write.
422
- * 4. a write flag (`--fix`, `--write`, `--in-place`, `--update-snapshots`,
423
- * `--output`, …) → mutating. Catches verify-class commands that still
424
- * rewrite files (`eslint --fix`).
425
- * 5. otherwise → not mutating (read-only and verify-class segments pass:
426
- * `ls`, `cat`, `rg`, `git status`, `npm test`, `tsc --noEmit`).
404
+ * The decision — and the five-step order it follows — lives in
405
+ * `analyzeCommand` (`permissions.ts`), the single pipeline the role gate and
406
+ * the write gate share. This is only the `WriteIntent` adapter, so the two
407
+ * gates cannot disagree about quoting, redirects or command position.
427
408
  */
428
409
  export function commandMayWrite(command: unknown): WriteIntent {
429
410
  if (typeof command !== "string" || !command.trim()) {
430
411
  return { mutating: false };
431
412
  }
432
- for (const segment of splitCommandSegments(command)) {
433
- const trimmed = segment.trim();
434
- if (!trimmed) continue;
435
-
436
- // 1. Forbidden syntax — a file redirect is a write whatever else runs.
437
- const syntax = forbiddenSyntaxReason(trimmed);
438
- if (syntax) {
439
- return { mutating: true, reason: syntax, segment: trimmed };
440
- }
441
-
442
- // Classify the command with harmless redirections (`2>&1`, `>/dev/null`)
443
- // removed, so `2>/dev/null ls` is judged as `ls` (read-only) rather than
444
- // as an unknown binary. A segment that is nothing but a redirection
445
- // writes no file.
446
- const scrubbed = stripHarmlessRedirects(trimmed).trim();
447
- if (!scrubbed) continue;
448
-
449
- // 2. Git history operations — governed by the commit policy, not here.
450
- if (gitCommitPolicyHit(scrubbed)) continue;
451
-
452
- // 3. Not provably read-only → may write.
453
- if (commandClassOf(scrubbed) === "full") {
454
- return {
455
- mutating: true,
456
- reason: "runs a command that may modify files",
457
- segment: trimmed,
458
- };
459
- }
460
-
461
- // 4. A write flag on an otherwise-safe command.
462
- const flag = scrubbed.match(DENIED_FLAG_RE);
463
- if (flag) {
464
- return {
465
- mutating: true,
466
- reason: `uses the write flag ${flag[2] ?? flag[0].trim()}`,
467
- segment: trimmed,
468
- };
469
- }
470
- }
471
- return { mutating: false };
413
+ const analysis = analyzeCommand(command);
414
+ if (!analysis.mutating) return { mutating: false };
415
+ const intent: WriteIntent = { mutating: true };
416
+ if (analysis.writeReason) intent.reason = analysis.writeReason;
417
+ if (analysis.writeSegment) intent.segment = analysis.writeSegment;
418
+ return intent;
472
419
  }
473
420
 
474
421
  /** Normalize a shell segment into the key used for allowlist matching. */