@dieulc/workflow 0.10.0 → 0.11.1
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 +113 -15
- package/commit-policy.ts +82 -10
- package/edit-policy.ts +121 -45
- package/folder-policy.ts +2165 -0
- package/index.ts +2493 -270
- package/package.json +9 -2
- package/read-policy-redact.ts +359 -0
- package/read-policy-tools.ts +312 -0
- package/read-policy.ts +672 -0
- package/review-handoff.ts +3 -2
- package/review-recovery.ts +4 -2
- package/review-runtime.ts +152 -52
- package/review-tools.ts +228 -1
- package/review.ts +209 -31
- package/shell-paths.ts +1066 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +166 -0
- package/workflow-status.ts +22 -8
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` |
|
|
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`, `exclude [add\|revoke\|list] <glob> [--global\|--project]` |
|
|
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
|
|
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
|
-
|
|
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
|
-
- **
|
|
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,67 @@ 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. For **shell commands** on Windows, Git-Bash's `/tmp` is also resolved to the real Git-Bash temp directory (`%LOCALAPPDATA%\Temp`, or a native `TMP`/`TEMP`; detected via Git for Windows or `MSYSTEM`, overridable with `PI_MSYS_TMP`), while direct tool paths keep Node's meaning (`/tmp` is `C:/tmp` there). 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
|
+
### Excluded folders
|
|
369
|
+
|
|
370
|
+
`folderPolicy.excludePaths` protects a folder you do **not** want the agent to hold standing permission over — typically an OS root or your home directory:
|
|
371
|
+
|
|
372
|
+
```jsonc
|
|
373
|
+
// ~/.pi/agent/settings.json — global is the usual scope here
|
|
374
|
+
{ "workflow": { "folderPolicy": { "excludePaths": ["C:/", "C:/Users/Lenovo"] } } }
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
An exclusion is **scope subtraction, not a secret**, so it differs from `sensitivePatterns` in one decisive way: a more specific approval can re-open a sub-folder.
|
|
378
|
+
|
|
379
|
+
| Target | No grant | Grant on the excluded folder | Grant inside it | Grant above it |
|
|
380
|
+
| --- | --- | --- | --- | --- |
|
|
381
|
+
| the excluded folder itself | **blocked** (no dialog) | allowed | blocked | blocked |
|
|
382
|
+
| a file/folder below it | outside → ask / block | allowed | allowed | outside |
|
|
383
|
+
| the session folder, strictly inside | allowed | allowed | allowed | allowed |
|
|
384
|
+
| the session folder **is** the excluded folder | **blocked** | allowed | blocked | blocked |
|
|
385
|
+
|
|
386
|
+
- The excluded folder itself never prompts and never offers a grant; the refusal names the rule and `exclude revoke`.
|
|
387
|
+
- Below it, normal `outside` rules apply: `outside: "ask"` lets the agent request one specific sub-folder (that approval is a grant inside the exclusion, so it sticks), `outside: "block"` refuses without a dialog.
|
|
388
|
+
- The session folder (and a spawned subagent's locked roots) only rescue automatically when **strictly inside** an exclusion, so opening your home directory as the cwd does not silently hand over the whole home.
|
|
389
|
+
- A grant at or above the excluded root never rescues; dialogs also refuse a one-click grant of a filesystem/drive root, the home directory, a parent of home, or a protected system/profile umbrella (`C:/Windows`, `Program Files`, `ProgramData`, the `AppData` umbrella, `/etc`, `/usr`, `/var`, `/System`, …), so “Always allow” cannot undo the protection by accident. A concrete folder *inside* one of those is still grantable, and a deliberate `/read-policy allow C:/` does re-open the root — that is an explicit user decision.
|
|
390
|
+
- Sensitive-file rules still win inside an approved subtree.
|
|
391
|
+
|
|
392
|
+
Commands (global by default — `--project` opts into `<cwd>/.pi/settings.json`):
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
/folder-policy exclude C:/ --global # add (the bare form adds)
|
|
396
|
+
/folder-policy exclude add ~ --project # add to the project file
|
|
397
|
+
/folder-policy exclude list # effective rules + which files supply them
|
|
398
|
+
/folder-policy exclude revoke C:/ # remove exactly one entry
|
|
399
|
+
/folder-policy exclude "C:/My Documents" # quote paths that contain spaces
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Scope is **flags only** (`--global` / `--project`). A bare `global`/`project` word is treated as an ordinary argument, so a folder literally named `project` can be excluded and a trailing word can never silently retarget the write; any leftover argument is rejected with the usage line instead of being dropped.
|
|
403
|
+
|
|
404
|
+
Input handling worth knowing:
|
|
405
|
+
|
|
406
|
+
- **Paths with spaces** are supported with quotes (`"…"` or `'…'`); backslashes stay literal, so Windows paths survive. An unbalanced quote is refused, not half-applied.
|
|
407
|
+
- **`C:Personal`** (drive-relative, no separator) is repaired to **`C:/Personal`** on Windows and the message says `(normalised from C:Personal)`. Without that repair a slash-less pattern would be a basename glob that never matches an absolute path — a listed rule that does nothing. Revoking works with either spelling.
|
|
408
|
+
- **Message levels:** a successful write is `info` (and prints the settings file plus the resulting `Active exclusions (N)` list, so a stale list can never look like a contradiction); a refused/ambiguous input or a non-blocking note is `warning`; `error` means the new rule blocks a capability at your session folder.
|
|
409
|
+
- **“Broad root” means an OS/drive/share root or your home directory** (`/`, `C:/`, `//server/share`, `~`). Excluding an ordinary folder such as `C:/Personal` is a precise request and gets no such note. Catch-all patterns (`*`, `**`, `**/*`) are refused; roots are allowed with the note.
|
|
410
|
+
|
|
411
|
+
Global and project `excludePaths` are **unioned**: a project can add an exclusion, never remove a global one. Project settings are only read when the project is trusted (`exclude list` says when it ignored them). When a rule covers the session folder, the warning is **per capability and grant-aware**: read, write and commit are each evaluated with that capability's own `allowedPaths`, so it names only the capabilities that are actually refused (e.g. `Refused at the session folder: write, commit` + `Still covered by a grant: read`). `/folder-policy status` uses the same check.
|
|
412
|
+
|
|
413
|
+
**Workflow settings are user-owned.** Tool writes (`edit`, `write`, shell redirects/`sed -i`) that target the global `~/.pi/agent/settings.json` or the project `.pi/settings.json` are refused in every mode, including Build — otherwise the agent could delete its own exclusions. The slash commands write those files through the filesystem and still work; ask the user to run one, or to edit the file themselves.
|
|
414
|
+
|
|
415
|
+
**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.
|
|
416
|
+
|
|
417
|
+
**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. A dialog may remember a concrete folder — including a drive-top folder such as `C:/tmp` and the real Git-Bash temp dir — but not a filesystem/drive root, the home directory, a parent of home, or a protected system/profile umbrella (`C:/Windows`, `Program Files`, `ProgramData`, the `AppData` umbrella, `/etc`, `/usr`, `/var`, `/System`, …); a concrete folder *inside* one of those is grantable. When a grant cannot be persisted (broad root, or an untrusted project with project scope) the dialog **omits** the “Always allow” row, explains why, and names the deliberate path: `/read-policy allow <target> [global|project]` for reads; write and commit grants go in `workflow.writePolicy.allowedPaths` / `workflow.commitPolicy.allowedPaths` (see `/write-policy status` and `/commit-policy status`). If an approval still arrives, the operation runs **once**, nothing is saved, and a warning says so. “Broad” for an **exclusion** is deliberately different and narrower (only an OS/drive/share root or home) because an exclusion is a narrowing action. Use `/folder-policy any` deliberately to disable the floor for read, write and commit.
|
|
418
|
+
|
|
419
|
+
**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 same honesty applies to `excludePaths`: `grep -r` still traverses excluded files before the output is redacted, project-wide tools (`symbol_search`, `project_report`, a path-less `lens_diagnostics`) can surface names or symbols from an excluded folder, and opaque code is not path-analysed — the exclusions stop the agent's *tools* from reading or writing there, not an arbitrary subprocess. The Review Mode reviewer gets guarded built-in `read`/`grep`/`find`/`ls` tools even though its session has no ambient extensions, and inherits the parent's exclusions and read grants; `review_bash` uses the same guard. Spawned subagents receive locked roots plus the parent's deny layer (`PI_FOLDER_POLICY_DENY`) and cannot expand via a sibling project's settings.
|
|
420
|
+
|
|
421
|
+
**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.
|
|
422
|
+
|
|
359
423
|
## Commit policy
|
|
360
424
|
|
|
361
425
|
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.
|
|
@@ -371,6 +435,8 @@ The default exists because prompt-only rules are soft: models habitually commit
|
|
|
371
435
|
|
|
372
436
|
### Modes
|
|
373
437
|
|
|
438
|
+
Exclusions and sensitive files apply to commits too: `git add <sensitive|excluded>`, `git add .`/`-A` when such a path would be included, and a staged sensitive/excluded path before `git commit` are refused. `/commit` bypasses the commit *mode* (with your confirmation), never the sensitive-file or exclusion checks. Per-step auto-commit skips with a notice when the repository — or any dirty path it would stage — is excluded.
|
|
439
|
+
|
|
374
440
|
| Mode | Meaning |
|
|
375
441
|
| --- | --- |
|
|
376
442
|
| `never` (default) | `git add` / `git commit` / `git push` are blocked with a policy reason that tells the model to leave changes uncommitted for review |
|
|
@@ -379,6 +445,8 @@ The default exists because prompt-only rules are soft: models habitually commit
|
|
|
379
445
|
|
|
380
446
|
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
447
|
|
|
448
|
+
**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.
|
|
449
|
+
|
|
382
450
|
### `/commit` under any policy
|
|
383
451
|
|
|
384
452
|
`/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 +471,12 @@ Known limits (accepted, same trade-off OpenCode/Claude Code make): a configured
|
|
|
403
471
|
|
|
404
472
|
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
473
|
|
|
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)
|
|
474
|
+
`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
475
|
|
|
408
476
|
| Mode | Meaning |
|
|
409
477
|
| --- | --- |
|
|
410
478
|
| `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 |
|
|
479
|
+
| `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'**. If a persistent grant is impossible (a root/home/protected umbrella, or an untrusted project with project scope), the Always row is omitted and an approval applies once with a warning |
|
|
412
480
|
| `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
481
|
|
|
414
482
|
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 +485,18 @@ The status line always shows the active permission (`◦ commits:never · writes
|
|
|
417
485
|
|
|
418
486
|
- The `edit` and `write` tools.
|
|
419
487
|
- `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
|
|
488
|
+
- **`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
489
|
- `powershell` is always treated as a write — it cannot be classified by the POSIX allowlist, so it fails closed.
|
|
422
490
|
|
|
423
491
|
`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
492
|
|
|
425
493
|
### Scope and carve-outs
|
|
426
494
|
|
|
427
|
-
- **Default mode only.** Plan mode keeps its stricter read-only rules (writes only to `.pi/plans/`), and Build
|
|
495
|
+
- **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, sensitive files and [excluded folders](#excluded-folders)).
|
|
496
|
+
- **Settings files are user-owned.** A write whose target is the global `~/.pi/agent/settings.json` or the project `.pi/settings.json` is refused in every mode, so the agent cannot delete its own exclusions; the slash commands still write them.
|
|
428
497
|
- **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
498
|
- **`.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).
|
|
499
|
+
- **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
500
|
|
|
432
501
|
```text
|
|
433
502
|
/write-policy # status: mode, which scope supplied it, the allowlists
|
|
@@ -595,9 +664,35 @@ Write Permission settings live under `workflow.writePolicy` (same global/project
|
|
|
595
664
|
|
|
596
665
|
`"writePolicy": "ask"` (a bare mode string) is accepted as shorthand for `{ "mode": "ask" }`. Booleans, numbers and unknown modes fall back to the default. `/write-policy` writes this key read-modify-write, preserving every other settings key; the three list fields are **additive** — a write never drops the entries already stored (remove one by editing the JSON directly).
|
|
597
666
|
|
|
667
|
+
Folder Policy settings live under `workflow.folderPolicy` (scalars project-over-global; `sensitivePatterns`, `sensitiveExemptions` and `excludePaths` are **unioned**, so a trusted project can add a rule but never remove a global one):
|
|
668
|
+
|
|
669
|
+
| Key | Default | Meaning |
|
|
670
|
+
| --- | --- | --- |
|
|
671
|
+
| `mode` | `"repo"` | Base folder: `repo` (nearest `.git`, fallback cwd), `cwd`, `parent` (siblings), `any` (containment off; sensitive files and exclusions still apply) |
|
|
672
|
+
| `outside` | `"ask"` | `ask` = prompt once per turn for a target outside the floor (headless fails closed); `block` = refuse without a dialog |
|
|
673
|
+
| `grantScope` | `"project"` | Where “Always allow” persists grants (`project` \| `global`); a project grant needs a trusted project |
|
|
674
|
+
| `sensitiveTemplates` | `"allow"` | `block` treats `.env.example`-style templates as sensitive |
|
|
675
|
+
| `sensitivePatterns` | `[]` | Extra always-blocked globs (additive on top of the built-in list) |
|
|
676
|
+
| `sensitiveExemptions` | `[]` | Manual exemptions; never written by a dialog |
|
|
677
|
+
| `excludePaths` | `[]` | Folders the agent has no standing permission over. See [Excluded folders](#excluded-folders) for the rescue rule |
|
|
678
|
+
|
|
679
|
+
```jsonc
|
|
680
|
+
{
|
|
681
|
+
"workflow": {
|
|
682
|
+
"folderPolicy": {
|
|
683
|
+
"mode": "repo",
|
|
684
|
+
"outside": "ask",
|
|
685
|
+
"excludePaths": ["C:/", "~"]
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
`"folderPolicy": "ask"` is **not** shorthand — a bare string is read as a `mode` (`"repo"`, `"cwd"`, `"parent"`, `"any"`), matching the earlier release. Project settings (all four `workflow.*` policies) are ignored unless the project is trusted; `/folder-policy status` says when it ignored them. Dialog grants are limited to concrete folders: the filesystem/drive root, home and its parents, and protected system/profile umbrellas are refused as one-click grants (see [Grants](#folder-and-read-permission)).
|
|
692
|
+
|
|
598
693
|
## How it works
|
|
599
694
|
|
|
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.
|
|
695
|
+
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
696
|
|
|
602
697
|
### Todo protocol (industry-aligned)
|
|
603
698
|
|
|
@@ -605,7 +700,8 @@ See `index.ts`, `checkpoint.ts` (shadow bare-repo), `commit-policy.ts` (commit p
|
|
|
605
700
|
- **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
701
|
- **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
702
|
- **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
|
|
703
|
+
- **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.
|
|
704
|
+
- **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
705
|
|
|
610
706
|
## Troubleshooting
|
|
611
707
|
|
|
@@ -629,6 +725,8 @@ The warning reads `review skipped (the reviewer's provider failed after 3 retry
|
|
|
629
725
|
|
|
630
726
|
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
727
|
|
|
728
|
+
**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.
|
|
729
|
+
|
|
632
730
|
If the text is specifically `Stream ended without finish_reason`, the gateway closed a long
|
|
633
731
|
SSE response before Pi saw a terminal reason. Pi treats that as a fatal provider error, and
|
|
634
732
|
the retry repeats the same long request. Lowering the thinking level may shorten it, but it
|
|
@@ -648,7 +746,7 @@ What to do:
|
|
|
648
746
|
cd agent/extensions/workflow
|
|
649
747
|
npm install
|
|
650
748
|
npm run typecheck # tsc --noEmit (covers every source file, including the review modules)
|
|
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
|
|
749
|
+
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 folder-policy.test.ts folder-exclude.test.ts
|
|
652
750
|
```
|
|
653
751
|
|
|
654
752
|
The write policy also has an end-to-end check that drives the real `tool_call` handler against the real `settings.json` (it temporarily writes your global settings and restores them afterwards):
|
package/commit-policy.ts
CHANGED
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
import * as fs from "node:fs";
|
|
25
25
|
import * as path from "node:path";
|
|
26
26
|
import type { ArgumentCompletion } from "./utils.ts";
|
|
27
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
137
|
-
project: path.join(
|
|
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
|
|
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,
|
|
250
|
+
mode: modeSourceOf(globalRaw, effectiveProjectRaw),
|
|
208
251
|
},
|
|
209
252
|
};
|
|
210
253
|
}
|
|
@@ -476,6 +519,12 @@ 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;
|
|
526
|
+
/** The repository or a staged path is an excluded folder. */
|
|
527
|
+
excluded?: boolean;
|
|
479
528
|
}
|
|
480
529
|
|
|
481
530
|
/**
|
|
@@ -486,12 +535,26 @@ export interface CommitGateInput {
|
|
|
486
535
|
*/
|
|
487
536
|
export function decideCommitGate(input: CommitGateInput): CommitGateDecision {
|
|
488
537
|
const { policy, hit, hasTodos } = input;
|
|
538
|
+
if (input.sensitive) return "block";
|
|
539
|
+
// An excluded path is refused before the authorization exemption is
|
|
540
|
+
// consulted, so a blocked attempt never burns a `/commit` authorization.
|
|
541
|
+
if (input.excluded) return "block";
|
|
542
|
+
if (input.folder === "block") return "block";
|
|
543
|
+
if (
|
|
544
|
+
hit === "commit" &&
|
|
545
|
+
isAuthorizationActive(input.auth, input.now) &&
|
|
546
|
+
input.folder !== "ask"
|
|
547
|
+
) {
|
|
548
|
+
return "allow";
|
|
549
|
+
}
|
|
550
|
+
if (policy === "never") return "block";
|
|
551
|
+
if (input.folder === "ask") return "ask";
|
|
489
552
|
if (policy === "allow" && !hasTodos) return "allow";
|
|
490
553
|
if (policy === "ask" && hit === "add") return "allow";
|
|
491
554
|
if (hit === "commit" && isAuthorizationActive(input.auth, input.now)) {
|
|
492
555
|
return "allow";
|
|
493
556
|
}
|
|
494
|
-
if (policy === "
|
|
557
|
+
if (policy === "allow") return "block";
|
|
495
558
|
return "ask";
|
|
496
559
|
}
|
|
497
560
|
|
|
@@ -507,6 +570,9 @@ export function writeCommitPolicyConfig(
|
|
|
507
570
|
): { path: string; ok: boolean; error?: string } {
|
|
508
571
|
const paths = commitConfigPaths(cwd, agentDir);
|
|
509
572
|
const fp = scope === "project" ? paths.project : paths.global;
|
|
573
|
+
if (process.env.PI_FOLDER_POLICY_LOCKED === "1") {
|
|
574
|
+
return { path: fp, ok: false, error: "commit policy is locked for this subagent" };
|
|
575
|
+
}
|
|
510
576
|
try {
|
|
511
577
|
const existing = readJsonSafe(fp);
|
|
512
578
|
const root: JsonObject = existing ? { ...existing } : {};
|
|
@@ -516,7 +582,13 @@ export function writeCommitPolicyConfig(
|
|
|
516
582
|
? { ...(wfExisting as JsonObject) }
|
|
517
583
|
: {};
|
|
518
584
|
const current = coerceCommitPolicyConfig(wf[COMMIT_POLICY_KEY]);
|
|
519
|
-
const next =
|
|
585
|
+
const next: CommitPolicyConfig = {
|
|
586
|
+
mode: coerceMode(patch.mode) ?? current.mode,
|
|
587
|
+
allowedPaths: unionStrings(
|
|
588
|
+
current.allowedPaths,
|
|
589
|
+
coerceStringList(patch.allowedPaths) ?? [],
|
|
590
|
+
),
|
|
591
|
+
};
|
|
520
592
|
wf[COMMIT_POLICY_KEY] = next;
|
|
521
593
|
root[WORKFLOW_SETTINGS_KEY] = wf;
|
|
522
594
|
fs.mkdirSync(path.dirname(fp), { recursive: true });
|