@dieulc/workflow 0.9.0 → 0.9.2

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
@@ -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
 
@@ -156,11 +157,11 @@ The policy classifies commands into three classes and maps them to roles:
156
157
  | explorer | read-only | none | `explore` / `review_explore` subagents |
157
158
  | builder | full (unrestricted) | all | Build Mode / normal sessions |
158
159
 
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.
160
+ - **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
161
  - **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
162
  - **full** — no gate (Builder and normal sessions only).
162
163
 
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.
164
+ 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
165
 
165
166
  The built-in `bash`, `edit` and `write` tools are never given to the reviewer at all.
166
167
 
@@ -226,7 +227,7 @@ Input follows the **active mode**, decided automatically — there is nothing to
226
227
 
227
228
  Delivery to the reviewer is best-effort: if it fails, the message stays in the plan-agent chat rather than being swallowed.
228
229
 
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`.
230
+ **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
231
 
231
232
  ### Controlling a running review
232
233
 
@@ -234,6 +235,10 @@ The handoff **waits for the reviewer**: no browser prompt or inline choice rende
234
235
 
235
236
  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
237
 
238
+ **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.
239
+
240
+ 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.
241
+
237
242
  `/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
243
 
239
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.
@@ -245,7 +250,7 @@ A handoff opens **only when the current run writes or edits a `.pi/plans/` file
245
250
  - `/review say <message>` talks to the in-flight reviewer.
246
251
  - `/review-status` shows phase, round, the snapshotted model, findings, any paused state, and the mode-switch lock with its allowed exits.
247
252
 
248
- Pausing is in-memory only: a reload starts fresh.
253
+ 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
254
 
250
255
  ### Isolation
251
256
 
@@ -362,7 +367,7 @@ The status line always shows the active permission (`◦ commits:never · writes
362
367
  ### What is gated
363
368
 
364
369
  - 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.
370
+ - `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
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.
367
372
  - `powershell` is always treated as a write — it cannot be classified by the POSIX allowlist, so it fails closed.
368
373
 
@@ -475,8 +480,8 @@ Review Mode settings live under `workflow.reviewMode`:
475
480
 
476
481
  | Key | Default | Meaning |
477
482
  | --- | --- | --- |
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 |
483
+ | `enabled` | `false` | Automatic-review toggle. When false, the handoff review/prefetch/planner note are off; the manual `/review*` commands still work |
484
+ | `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
485
  | `passes` | `2` | `1` = review+rewrite only; `2` = review, then self-verify |
481
486
  | `verify` | `true` | Run the self-verification pass |
482
487
  | `fallbackOnError` | `"skip"` | `skip` hands over the author's plan on any failure (`block` is reserved) |
@@ -518,7 +523,7 @@ Write Permission settings live under `workflow.writePolicy` (same global/project
518
523
  | --- | --- | --- |
519
524
  | `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
525
  | `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 |
526
+ | `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
527
  | `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
528
 
524
529
  ```json
@@ -581,7 +586,7 @@ What to do:
581
586
  cd agent/extensions/workflow
582
587
  npm install
583
588
  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
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
585
590
  ```
586
591
 
587
592
  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. */