@dieulc/workflow 0.11.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 CHANGED
@@ -45,7 +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
- | `/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` |
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
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 |
50
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 |
51
51
 
@@ -361,15 +361,62 @@ Settings live under `workflow.reviewMode` in `~/.pi/agent/settings.json` (global
361
361
 
362
362
  ## Folder and read permission
363
363
 
364
- **One shared floor.** `workflow.folderPolicy` decides which folders read, write and commit may touch. `mode: "repo"` (default) is the nearest `.git` ancestor (fallback: the session cwd); `cwd` is the exact working directory; `parent` is the parent of that base (siblings); `any` disables containment. `outside: "ask"` prompts once per turn (headless fails closed); `outside: "block"` refuses silently, and an unresolvable write/commit target is refused without a dialog (the read gate keeps its own `readPolicy.shell` opt-in). A policy can only be **stricter** than this floor — `writePolicy: "allow"` cannot write outside it. Both Default and Build respect the floor. For writes it asks only for a *proven* outside target, a sensitive file, or an explicit path that cannot be resolved; a command whose writes merely cannot be located is not treated as outside (see “Not a sandbox”). Windows Git-Bash/Cygwin/WSL spellings (`/c/...`, `/cygdrive/c/...`, `/mnt/c/...`) and `file://` paths are translated to native paths before matching; a folder grant covers the first folder outside the base (the outside project), while a file sitting directly in the common parent is granted as that file only. Read, write and commit dialogs list the base/current folder and every requested target path; a prompt caused by the floor is titled **Folder policy 'ask'**, not “Write policy”.
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
365
 
366
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
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
+
368
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.
369
416
 
370
- **Grants.** “Always allow” writes to the capability's `allowedPaths` in project settings by default (`folderPolicy.grantScope: "project"`); the project must be trusted. Existing global write grants remain honoured. “Always allow” cannot grant a filesystem root/home from a dialog — use `/folder-policy any` deliberately.
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.
371
418
 
372
- **Not a sandbox.** Arbitrary code (`node -e`, `python -c`, package scripts), renames of sensitive files and git history are documented residuals. A shell command whose writes cannot be located — `npm run build`, `node script`, `npx`, a `sed`/`awk` program, `powershell`, or a path-less tool in `mutatingTools` — is **not** treated as an outside write: under `writePolicy: "allow"` (and in Build) it runs without a dialog. That is deliberate: the analyzer cannot see inside project code, and asking made ordinary in-repo builds and tests look like a permission violation. Use `writePolicy: "ask"` for a prompt on every mutating call, or an OS container/VM for a hard boundary. The Review Mode reviewer gets guarded built-in `read`/`grep`/`find`/`ls` tools even though its session has no ambient extensions; `review_bash` uses the same read guard. Spawned subagents receive locked roots and cannot expand via a sibling project's settings.
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.
373
420
 
374
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.
375
422
 
@@ -388,6 +435,8 @@ The default exists because prompt-only rules are soft: models habitually commit
388
435
 
389
436
  ### Modes
390
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
+
391
440
  | Mode | Meaning |
392
441
  | --- | --- |
393
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 |
@@ -427,7 +476,7 @@ The extension has three modes — **Plan → Build → Default**. Plan mode is r
427
476
  | Mode | Meaning |
428
477
  | --- | --- |
429
478
  | `allow` (default) | The agent writes files freely — today's behaviour, and the shipped default so existing installs are unaffected |
430
- | `ask` | Writes pop a dialog: **Allow once (this turn)** / **Always allow …** (*this path*, *these paths*, *this folder*, *these folders*, *this command*, *these commands*) / **Deny** / **Deny with instructions…**. One dialog covers a whole turn and lists the writes it covers; a permanent grant is remembered, so the same target stops asking. A prompt caused by the folder floor is titled **Folder policy 'ask'** and shows the base folder; a write-mode prompt is **Write policy 'ask'** |
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 |
431
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 |
432
481
 
433
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.
@@ -443,7 +492,8 @@ The status line always shows the active permission (`◦ commits:never · writes
443
492
 
444
493
  ### Scope and carve-outs
445
494
 
446
- - **Default mode only for the write mode.** Plan mode keeps its stricter read-only rules (writes only to `.pi/plans/`), and Build stays the execution escape hatch: it never inherits `writePolicy: ask/never`. The shared folder floor still applies in Build and Default (outside targets and sensitive files).
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.
447
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.
448
498
  - **`.pi/plans/` writes stay allowed** in every mode, so a session that leaves Plan mode mid-flow is not stranded.
449
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`.
@@ -614,6 +664,32 @@ Write Permission settings live under `workflow.writePolicy` (same global/project
614
664
 
615
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).
616
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
+
617
693
  ## How it works
618
694
 
619
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.
@@ -670,7 +746,7 @@ What to do:
670
746
  cd agent/extensions/workflow
671
747
  npm install
672
748
  npm run typecheck # tsc --noEmit (covers every source file, including the review modules)
673
- npm test # node --test utils.todo.test.ts permissions.test.ts shell-text.test.ts roles.test.ts profiles.test.ts provider-retarget.test.ts thinking-levels.test.ts workflow-status.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts
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
674
750
  ```
675
751
 
676
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
@@ -523,6 +523,8 @@ export interface CommitGateInput {
523
523
  folder?: "allow" | "ask" | "block";
524
524
  /** A sensitive path would be added/committed. */
525
525
  sensitive?: boolean;
526
+ /** The repository or a staged path is an excluded folder. */
527
+ excluded?: boolean;
526
528
  }
527
529
 
528
530
  /**
@@ -534,6 +536,9 @@ export interface CommitGateInput {
534
536
  export function decideCommitGate(input: CommitGateInput): CommitGateDecision {
535
537
  const { policy, hit, hasTodos } = input;
536
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";
537
542
  if (input.folder === "block") return "block";
538
543
  if (
539
544
  hit === "commit" &&
@@ -565,6 +570,9 @@ export function writeCommitPolicyConfig(
565
570
  ): { path: string; ok: boolean; error?: string } {
566
571
  const paths = commitConfigPaths(cwd, agentDir);
567
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
+ }
568
576
  try {
569
577
  const existing = readJsonSafe(fp);
570
578
  const root: JsonObject = existing ? { ...existing } : {};
package/edit-policy.ts CHANGED
@@ -310,6 +310,9 @@ export function writeWritePolicyConfig(
310
310
  ): { path: string; ok: boolean; error?: string } {
311
311
  const paths = writePolicyConfigPaths(cwd, agentDir);
312
312
  const fp = scope === "project" ? paths.project : paths.global;
313
+ if (process.env.PI_FOLDER_POLICY_LOCKED === "1") {
314
+ return { path: fp, ok: false, error: "write policy is locked for this subagent" };
315
+ }
313
316
  try {
314
317
  const existing = readJsonSafe(fp);
315
318
  const root: JsonObject = existing ? { ...existing } : {};
@@ -716,10 +719,11 @@ export function sanitizeWriteInstruction(raw: unknown): string {
716
719
  */
717
720
  export function writeGateOptions(
718
721
  kind: WriteGateOptionKind,
722
+ opts: { allowAlways?: boolean } = {},
719
723
  ): readonly string[] {
720
724
  return [
721
725
  "Allow once (this turn)",
722
- writeGateAlwaysLabel(kind),
726
+ ...(opts.allowAlways !== false ? [writeGateAlwaysLabel(kind)] : []),
723
727
  "Deny",
724
728
  WRITE_DENY_WITH_INSTRUCTIONS_LABEL,
725
729
  ];
@@ -1014,9 +1018,13 @@ export function writeGateTitle(
1014
1018
  items: readonly WriteGateItem[],
1015
1019
  source: WriteGateSource = "write",
1016
1020
  base?: string,
1021
+ opts: { grantBlockedReason?: string } = {},
1017
1022
  ): string {
1018
1023
  const outside =
1019
1024
  source === "folder" && base ? `\nOutside: ${base}` : "";
1025
+ const blocked = opts.grantBlockedReason
1026
+ ? `\nGrant not available: ${opts.grantBlockedReason}`
1027
+ : "";
1020
1028
  const header =
1021
1029
  source === "folder"
1022
1030
  ? "Folder policy 'ask': allow this write outside the permitted folders?"
@@ -1031,7 +1039,8 @@ export function writeGateTitle(
1031
1039
  header +
1032
1040
  outside +
1033
1041
  (target ? `\nTarget: ${target}` : "") +
1034
- (reason && reason !== target ? `\nReason: ${reason}` : "")
1042
+ (reason && reason !== target ? `\nReason: ${reason}` : "") +
1043
+ blocked
1035
1044
  );
1036
1045
  }
1037
1046
  const lines = items
@@ -1045,7 +1054,7 @@ export function writeGateTitle(
1045
1054
  : source === "mixed"
1046
1055
  ? `Permission required: allow these ${items.length} writes? (write policy and folder policy are 'ask')`
1047
1056
  : `Write policy 'ask': allow these ${items.length} writes?`;
1048
- return `${many}${outside}\n${lines.join("\n")}`;
1057
+ return `${many}${outside}\n${lines.join("\n")}${blocked}`;
1049
1058
  }
1050
1059
 
1051
1060
  // ── Argument completions (/write-policy) ────────────────────────────