@dieulc/workflow 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -12
- package/edit-policy.ts +193 -14
- package/index.ts +367 -50
- package/package.json +3 -2
- package/profiles.ts +25 -1
package/README.md
CHANGED
|
@@ -319,15 +319,16 @@ The extension has three modes — **Plan → Build → Default**. Plan mode is r
|
|
|
319
319
|
| Mode | Meaning |
|
|
320
320
|
| --- | --- |
|
|
321
321
|
| `allow` (default) | The agent writes files freely — today's behaviour, and the shipped default so existing installs are unaffected |
|
|
322
|
-
| `ask` |
|
|
323
|
-
| `never` | The agent may not
|
|
322
|
+
| `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 |
|
|
323
|
+
| `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 |
|
|
324
324
|
|
|
325
325
|
The status line always shows the active permission (`◦ commits:never · writes:ask`), so you can see at a glance what the agent may touch.
|
|
326
326
|
|
|
327
327
|
### What is gated
|
|
328
328
|
|
|
329
329
|
- The `edit` and `write` tools.
|
|
330
|
-
- `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
|
|
330
|
+
- `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.
|
|
331
|
+
- **`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.
|
|
331
332
|
- `powershell` is always treated as a write — it cannot be classified by the POSIX allowlist, so it fails closed.
|
|
332
333
|
|
|
333
334
|
`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.
|
|
@@ -359,18 +360,18 @@ Write denied — tell the agent how to proceed
|
|
|
359
360
|
The instruction reaches the model through **two channels**, on purpose:
|
|
360
361
|
|
|
361
362
|
1. **The tool result** — the block reason quotes your sentence verbatim and ends with the disposition (*"Do not retry this write. Follow the instruction above. If it cannot be followed, say so and stop."*).
|
|
362
|
-
2. **A user message** — the same sentence is delivered as a real user turn (`deliverAs: "steer"`), after the current tool batch and before the next model call.
|
|
363
|
+
2. **A user message** — the same sentence is delivered as a real user turn (`deliverAs: "steer"`), after the current tool batch and before the next model call. One user turn per turn, not one per blocked call.
|
|
363
364
|
|
|
364
365
|
The second channel is not redundancy for its own sake: Claude Code shipped the comment-on-rejection design and then shipped a bug where the model ignored the comment (it kept trying workarounds) — [anthropics/claude-code#89231](https://github.com/anthropics/claude-code/issues/89231), [#52824](https://github.com/anthropics/claude-code/issues/52824). A tool result is a fact the model may reinterpret; a user turn is an instruction it must follow. Cursor and Claude Code now offer the same affordance (*"Reject? Tell what to do differently"*).
|
|
365
366
|
|
|
366
367
|
Behaviour worth knowing:
|
|
367
368
|
|
|
368
|
-
- **
|
|
369
|
+
- **One dialog per turn, not per call.** Pi runs an assistant message's tool calls as a batch, and the gate is asked about *every* call of that batch before the first of them runs. Asking per call therefore asked the same question twice when the model batched two writes to one file — and the sentence typed into the first dialog could not reach the model before the second one opened (a tool result is only appended after the whole batch, and a user turn lands only after the batch has run). The dialog now lists every write the answer covers, and the answer applies to all of them: **Allow once (this turn)** allows the listed writes of that turn, **Always allow …** persists a grant for every one of them.
|
|
370
|
+
- **A denial is remembered for the rest of the run.** If the agent tries the same write again in a later turn, it is denied again with the same sentence — no second dialog, nothing to retype. The memory ends with the run (the next run asks again). To let that write through sooner, end the run (Esc) and start a new one, or switch to `/build` — the gate only applies in Default mode. Your sentence is never written to `settings.json`; it lives in the transcript and in one audit entry per turn.
|
|
369
371
|
- **A plain Deny behaves exactly as it always has** — the agent's run is not cut short, and the model keeps its turn. A denial never sets pi's `terminate` flag: that flag is only consulted *after* a whole tool batch has run, so it cannot stop a sibling write — it would only take away the model's next turn.
|
|
370
|
-
- **For a durable "explore only, don't edit yet", use `/plan`.** Plan mode is read-only by design and keeps the agent inside that rule for the whole session; a denial sentence applies to
|
|
372
|
+
- **For a durable "explore only, don't edit yet", use `/plan`.** Plan mode is read-only by design and keeps the agent inside that rule for the whole session; a denial sentence applies to the run it was given in.
|
|
371
373
|
- **Cancelling the text box is a plain Deny** — never an implicit allow. An empty or whitespace-only sentence behaves the same way.
|
|
372
374
|
- **Long or noisy input is sanitized**: line endings are normalised, control characters are stripped, and the text is capped at 2000 characters.
|
|
373
|
-
- **One prompt per gated call.** If the model asks for several writes in one turn, each is a separate dialog.
|
|
374
375
|
|
|
375
376
|
## Settings
|
|
376
377
|
|
|
@@ -432,10 +433,10 @@ Write Permission settings live under `workflow.writePolicy` (same global/project
|
|
|
432
433
|
|
|
433
434
|
| Key | Default | Meaning |
|
|
434
435
|
| --- | --- | --- |
|
|
435
|
-
| `mode` | `"allow"` | `"allow"` = the agent writes freely in Default mode; `"ask"` = every write prompts; `"never"` = writes are blocked (you write, or the user switches to `/build`) |
|
|
436
|
-
| `allowedPaths` | `[]` | Absolute paths the agent may write without being asked again. An entry covers the file itself and everything under it on a directory boundary |
|
|
437
|
-
| `allowedCommands` | `[]` | Command keys the agent may run without being asked again (the mutating shell segment, e.g. `rm -rf build`) |
|
|
438
|
-
| `mutatingTools` | `[]` | Extra tool names to treat as file writes (e.g. `ast_grep_replace`, `office_excel_write_cells`) |
|
|
436
|
+
| `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`) |
|
|
437
|
+
| `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 |
|
|
438
|
+
| `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 |
|
|
439
|
+
| `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`) |
|
|
439
440
|
|
|
440
441
|
```json
|
|
441
442
|
{
|
|
@@ -504,9 +505,12 @@ The write policy also has an end-to-end check that drives the real `tool_call` h
|
|
|
504
505
|
|
|
505
506
|
```bash
|
|
506
507
|
node --experimental-transform-types scripts/verify-write-policy.mjs
|
|
508
|
+
# or, equivalently (and the form the repo's own agent can run — the raw
|
|
509
|
+
# `node` binary is outside a read-only role's command allowlist):
|
|
510
|
+
npm run check:policy
|
|
507
511
|
```
|
|
508
512
|
|
|
509
|
-
It covers the allow/ask/never modes, the "always allow" grant,
|
|
513
|
+
It covers the allow/ask/never modes, the "always allow" grant, the guided denial (the instruction dialog, the two delivery channels, the audit entry, and the cancelled/empty fallbacks) and the turn-scoped gate: one dialog per assistant turn, the denial remembered for the run, and a batch grant covering every listed target. What it cannot check is the part that matters most: how the real TUI renders the text box, and whether a live model actually obeys your instruction — do those two by hand once.
|
|
510
514
|
|
|
511
515
|
Plain TypeScript, no build step — Pi loads `index.ts` directly (via jiti). From the repo root, `npm run verify` checks this package's manifest, tarball contents, tests and load path before a release. Live reload: edit the `.ts` file and run `/reload` in Pi.
|
|
512
516
|
|
package/edit-policy.ts
CHANGED
|
@@ -753,23 +753,44 @@ export function sanitizeWriteInstruction(raw: unknown): string {
|
|
|
753
753
|
* The gate consumes this and `parseWriteGateChoice` below, so a renamed
|
|
754
754
|
* label can never silently change the gate's behaviour (the unit test maps
|
|
755
755
|
* every real option through the parser).
|
|
756
|
+
*
|
|
757
|
+
* The labels state the scope they really have, because ONE answer now
|
|
758
|
+
* covers every gated write of the turn (see the turn-scoped helpers below):
|
|
759
|
+
* "Allow once (this turn)" allows the writes the dialog listed, and
|
|
760
|
+
* "Always allow …" persists a grant for every one of them.
|
|
756
761
|
*/
|
|
757
762
|
export function writeGateOptions(
|
|
758
|
-
kind:
|
|
763
|
+
kind: WriteGateOptionKind,
|
|
759
764
|
): readonly string[] {
|
|
760
|
-
return
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
765
|
+
return [
|
|
766
|
+
"Allow once (this turn)",
|
|
767
|
+
writeGateAlwaysLabel(kind),
|
|
768
|
+
"Deny",
|
|
769
|
+
WRITE_DENY_WITH_INSTRUCTIONS_LABEL,
|
|
770
|
+
];
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* The "always allow" label for a set of items — singular, plural or mixed.
|
|
775
|
+
*
|
|
776
|
+
* The prefix "Always allow" is what `parseWriteGateChoice` matches on, so
|
|
777
|
+
* every variant parses to the same choice.
|
|
778
|
+
*/
|
|
779
|
+
export function writeGateAlwaysLabel(kind: WriteGateOptionKind): string {
|
|
780
|
+
switch (kind) {
|
|
781
|
+
case "path":
|
|
782
|
+
return "Always allow this path";
|
|
783
|
+
case "paths":
|
|
784
|
+
return "Always allow these paths";
|
|
785
|
+
case "command":
|
|
786
|
+
return "Always allow this command";
|
|
787
|
+
case "commands":
|
|
788
|
+
return "Always allow these commands";
|
|
789
|
+
case "mixed":
|
|
790
|
+
return "Always allow these";
|
|
791
|
+
default:
|
|
792
|
+
return "Always allow these";
|
|
793
|
+
}
|
|
773
794
|
}
|
|
774
795
|
|
|
775
796
|
/** What the ask dialog's answer means. */
|
|
@@ -855,6 +876,164 @@ export function buildWriteGuidanceMessage(input: {
|
|
|
855
876
|
);
|
|
856
877
|
}
|
|
857
878
|
|
|
879
|
+
// ── Turn-scoped gate helpers ───────────────────────────────────────────
|
|
880
|
+
//
|
|
881
|
+
// The gate asks ONE question per assistant turn — not one per tool call.
|
|
882
|
+
//
|
|
883
|
+
// WHY: Pi executes an assistant message's tool calls as a BATCH, and the
|
|
884
|
+
// extension's `tool_call` hook runs in the batch's PREPARATION phase: once
|
|
885
|
+
// per call, sequentially, before any tool in the batch runs
|
|
886
|
+
// (pi-agent-core/dist/agent-loop.js → executeToolCallsParallel →
|
|
887
|
+
// prepareToolCall → beforeToolCall). A model that batches two writes to the
|
|
888
|
+
// same file therefore produced two dialogs back to back, with no agent
|
|
889
|
+
// output in between — and the instruction typed into the first one could
|
|
890
|
+
// not reach the model before the second one opened, because a tool result is
|
|
891
|
+
// appended only after the whole batch and a steer message lands only after
|
|
892
|
+
// the turn's tool calls have run. The user had to retype the same sentence.
|
|
893
|
+
//
|
|
894
|
+
// So the gate collects the turn's gated writes, shows them all in one
|
|
895
|
+
// dialog, and applies the answer to every one of them.
|
|
896
|
+
|
|
897
|
+
/** One gated write covered by a single dialog answer. */
|
|
898
|
+
export interface WriteGateItem {
|
|
899
|
+
/** The tool that would write (edit, write, bash, powershell, …). */
|
|
900
|
+
tool: string;
|
|
901
|
+
/** Human target: the joined paths, or the command/tool key. */
|
|
902
|
+
target: string;
|
|
903
|
+
/** Short cause, e.g. "edits src/a.ts". */
|
|
904
|
+
why?: string;
|
|
905
|
+
/** Stable key a denial is remembered under (see `writeGateTargetKey`). */
|
|
906
|
+
key: string;
|
|
907
|
+
/** Path grants "always allow" would persist for this item. */
|
|
908
|
+
grantPaths?: string[];
|
|
909
|
+
/** Command (or tool) grant "always allow" would persist for this item. */
|
|
910
|
+
grantCommand?: string;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Which "always allow" label fits a set of items.
|
|
915
|
+
*
|
|
916
|
+
* "path"/"paths" — every item writes files; "command"/"commands" — every
|
|
917
|
+
* item runs a command (or a path-less tool, whose grant is stored in the
|
|
918
|
+
* command list); "mixed" — both in one turn.
|
|
919
|
+
*/
|
|
920
|
+
export type WriteGateOptionKind =
|
|
921
|
+
| "path"
|
|
922
|
+
| "paths"
|
|
923
|
+
| "command"
|
|
924
|
+
| "commands"
|
|
925
|
+
| "mixed";
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* The stable key a denial is remembered under.
|
|
929
|
+
*
|
|
930
|
+
* Path writes key on their normalized absolute path(s) (so the same file
|
|
931
|
+
* keys the same way whatever the model spelled it as), command writes on
|
|
932
|
+
* their allow-key, and a path-less tool on its tool key.
|
|
933
|
+
*/
|
|
934
|
+
export function writeGateTargetKey(
|
|
935
|
+
toolName: string,
|
|
936
|
+
classification: WriteCallClassification,
|
|
937
|
+
cwd: string,
|
|
938
|
+
): string {
|
|
939
|
+
const paths = classification.paths
|
|
940
|
+
.map((p) => normalizeWritePath(p, cwd))
|
|
941
|
+
.filter((p) => p.length > 0)
|
|
942
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
943
|
+
if (paths.length > 0) return `path:${paths.join("|")}`;
|
|
944
|
+
if (classification.key) return `cmd:${commandAllowKey(classification.key)}`;
|
|
945
|
+
return `tool:${toolAllowKey(toolName)}`;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Build the item for one classified call.
|
|
950
|
+
*
|
|
951
|
+
* `grantPaths` / `grantCommand` are what "always allow" would persist for
|
|
952
|
+
* this item: the normalized paths for a file write, the allow-key for a
|
|
953
|
+
* command or a path-less tool.
|
|
954
|
+
*/
|
|
955
|
+
export function writeGateItemFromClassification(
|
|
956
|
+
toolName: string,
|
|
957
|
+
classification: WriteCallClassification,
|
|
958
|
+
cwd: string,
|
|
959
|
+
): WriteGateItem {
|
|
960
|
+
const tool = String(toolName ?? "");
|
|
961
|
+
const rawPaths = classification.paths.filter(
|
|
962
|
+
(p) => typeof p === "string" && p.trim().length > 0,
|
|
963
|
+
);
|
|
964
|
+
const grantPaths = rawPaths
|
|
965
|
+
.map((p) => normalizeWritePath(p, cwd))
|
|
966
|
+
.filter((p) => p.length > 0);
|
|
967
|
+
const item: WriteGateItem = {
|
|
968
|
+
tool,
|
|
969
|
+
target: rawPaths.length > 0 ? rawPaths.join(", ") : (classification.key ?? tool),
|
|
970
|
+
why: classification.reason,
|
|
971
|
+
key: writeGateTargetKey(tool, classification, cwd),
|
|
972
|
+
};
|
|
973
|
+
if (grantPaths.length > 0) item.grantPaths = grantPaths;
|
|
974
|
+
else if (classification.key) item.grantCommand = classification.key;
|
|
975
|
+
return item;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Which "always allow" label fits a set of items. */
|
|
979
|
+
export function writeGateItemsKind(
|
|
980
|
+
items: readonly WriteGateItem[],
|
|
981
|
+
): WriteGateOptionKind {
|
|
982
|
+
const paths = items.filter((i) => (i.grantPaths?.length ?? 0) > 0);
|
|
983
|
+
const commands = items.filter((i) => Boolean(i.grantCommand));
|
|
984
|
+
if (paths.length > 0 && commands.length > 0) return "mixed";
|
|
985
|
+
if (paths.length > 0) return paths.length === 1 ? "path" : "paths";
|
|
986
|
+
if (commands.length > 0) return commands.length === 1 ? "command" : "commands";
|
|
987
|
+
return "path";
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* The human list of the writes a dialog covers:
|
|
992
|
+
* "edits src/a.ts; edits src/b.ts".
|
|
993
|
+
*/
|
|
994
|
+
export function describeWriteGateItems(
|
|
995
|
+
items: readonly WriteGateItem[],
|
|
996
|
+
): string {
|
|
997
|
+
return items
|
|
998
|
+
.map((item) => {
|
|
999
|
+
const why = String(item.why ?? "").trim();
|
|
1000
|
+
return why || String(item.target ?? "").trim();
|
|
1001
|
+
})
|
|
1002
|
+
.filter((text) => text.length > 0)
|
|
1003
|
+
.join("; ");
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* The targets of a set of items, joined for the reason/guidance text:
|
|
1008
|
+
* "src/a.ts, src/b.ts".
|
|
1009
|
+
*/
|
|
1010
|
+
export function writeGateTargetsLabel(
|
|
1011
|
+
items: readonly WriteGateItem[],
|
|
1012
|
+
): string {
|
|
1013
|
+
return items
|
|
1014
|
+
.map((item) => String(item.target ?? "").trim())
|
|
1015
|
+
.filter((text) => text.length > 0)
|
|
1016
|
+
.join(", ");
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* The dialog title.
|
|
1021
|
+
*
|
|
1022
|
+
* One write keeps today's wording verbatim; several writes are listed, so
|
|
1023
|
+
* the user sees exactly what the answer covers.
|
|
1024
|
+
*/
|
|
1025
|
+
export function writeGateTitle(items: readonly WriteGateItem[]): string {
|
|
1026
|
+
if (items.length <= 1) {
|
|
1027
|
+
const only = items[0];
|
|
1028
|
+
const why = only?.why ? ` (${only.why})` : "";
|
|
1029
|
+
return `Write policy 'ask': allow this write${why}?`;
|
|
1030
|
+
}
|
|
1031
|
+
return (
|
|
1032
|
+
`Write policy 'ask': allow these ${items.length} writes? ` +
|
|
1033
|
+
`(${describeWriteGateItems(items)})`
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
858
1037
|
// ── Argument completions (/write-policy) ────────────────────────────
|
|
859
1038
|
|
|
860
1039
|
/** One /write-policy subcommand with its usage hint. */
|
package/index.ts
CHANGED
|
@@ -117,16 +117,23 @@ import {
|
|
|
117
117
|
decideWriteGate,
|
|
118
118
|
describeWritePolicyMode,
|
|
119
119
|
isWriteCallAllowed,
|
|
120
|
-
normalizeWritePath,
|
|
121
120
|
parseWriteGateChoice,
|
|
122
121
|
readWritePolicyConfig,
|
|
123
122
|
sanitizeWriteInstruction,
|
|
124
123
|
writePolicyArgumentCompletions,
|
|
125
124
|
writePolicyConfigPaths,
|
|
126
125
|
writeWritePolicyConfig,
|
|
126
|
+
writeGateItemFromClassification,
|
|
127
|
+
writeGateItemsKind,
|
|
127
128
|
writeGateOptions,
|
|
129
|
+
writeGateTargetsLabel,
|
|
130
|
+
writeGateTitle,
|
|
128
131
|
WRITE_INSTRUCTION_PROMPT_PLACEHOLDER,
|
|
129
132
|
WRITE_INSTRUCTION_PROMPT_TITLE,
|
|
133
|
+
type WriteGateChoice,
|
|
134
|
+
type WriteGateItem,
|
|
135
|
+
type WriteCallClassification,
|
|
136
|
+
type WritePolicyConfig,
|
|
130
137
|
type WritePolicyMode,
|
|
131
138
|
} from "./edit-policy.ts";
|
|
132
139
|
import {
|
|
@@ -176,6 +183,7 @@ import {
|
|
|
176
183
|
PROFILE_ROLES,
|
|
177
184
|
type ProfileConfig,
|
|
178
185
|
type Profile,
|
|
186
|
+
type ProfileRoleName,
|
|
179
187
|
} from "./profiles.ts";
|
|
180
188
|
import {
|
|
181
189
|
applySubmittedPlan,
|
|
@@ -457,6 +465,34 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
457
465
|
// already accounted for so they never trigger a spurious commit.
|
|
458
466
|
const committedStepRefs = new Set<string>();
|
|
459
467
|
let stepCommitsSeeded = false;
|
|
468
|
+
// ── Write-policy gate state (turn-scoped decisions, run-scoped denials) ──
|
|
469
|
+
// Pi runs an assistant message's tool calls as ONE batch, and the
|
|
470
|
+
// extension's tool_call hook runs in the batch's preparation phase — once
|
|
471
|
+
// per call, before any tool in the batch runs. Asking per call therefore
|
|
472
|
+
// asked the SAME question twice (two calls to the same path), and the
|
|
473
|
+
// instruction typed into the first dialog could not reach the model
|
|
474
|
+
// before the second one opened. The gate now asks once per turn:
|
|
475
|
+
// writeGateBatchCalls — the tool calls of the current assistant
|
|
476
|
+
// message (the turn's batch), captured on
|
|
477
|
+
// message_end; the dialog lists the writes it
|
|
478
|
+
// covers.
|
|
479
|
+
// writeGateTurnAnswer — the answer the user gave for this turn; it
|
|
480
|
+
// applies to every gated call of the turn, so
|
|
481
|
+
// the siblings are never asked again.
|
|
482
|
+
// writeGateTurnSteered — whether this turn already delivered the
|
|
483
|
+
// instruction as a user turn (at most one steer
|
|
484
|
+
// per turn).
|
|
485
|
+
// writeGateRunDenials — denials remembered for the rest of the run,
|
|
486
|
+
// keyed by the write's target key, so a later
|
|
487
|
+
// retry is auto-denied (no second dialog) with
|
|
488
|
+
// the same instruction.
|
|
489
|
+
let writeGateBatchCalls: { name: string; args: unknown }[] = [];
|
|
490
|
+
/** The tool-call ids of the cached batch — a call outside it starts a new one. */
|
|
491
|
+
let writeGateBatchCallIds = new Set<string>();
|
|
492
|
+
let writeGateTurnAnswer: { choice: WriteGateChoice; instruction: string } | null =
|
|
493
|
+
null;
|
|
494
|
+
let writeGateTurnSteered = false;
|
|
495
|
+
const writeGateRunDenials = new Map<string, { instruction: string }>();
|
|
460
496
|
// ── Reminder throttle + staleness tracking (passive enforcement layer) ──
|
|
461
497
|
let turnsSinceLastTodoWrite = 0;
|
|
462
498
|
let turnsSinceLastReminder = 0;
|
|
@@ -687,6 +723,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
687
723
|
` ${roleIcon(roleName)} ${roleName} → ${formatModelRef(m)} (${m.thinking})`,
|
|
688
724
|
);
|
|
689
725
|
}
|
|
726
|
+
if (profile.defaultModel) {
|
|
727
|
+
const m = profile.defaultModel;
|
|
728
|
+
lines.push(
|
|
729
|
+
` ◦ default → ${formatModelRef(m)} (${m.thinking})`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
690
732
|
const missing = missingProfileRoles(profile);
|
|
691
733
|
if (missing.length) {
|
|
692
734
|
lines.push(
|
|
@@ -736,6 +778,13 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
736
778
|
}
|
|
737
779
|
profileExplicit = opts?.explicit ?? true;
|
|
738
780
|
invalidateEffectiveRoles();
|
|
781
|
+
const prof = activeProfileName
|
|
782
|
+
? getProfile(profilesConfig, activeProfileName)
|
|
783
|
+
: null;
|
|
784
|
+
// Default-mode model follows the profile when it carries one.
|
|
785
|
+
if (prof?.defaultModel) {
|
|
786
|
+
defaultModel = { ...prof.defaultModel };
|
|
787
|
+
}
|
|
739
788
|
try {
|
|
740
789
|
persistState();
|
|
741
790
|
} catch (_e) {
|
|
@@ -748,6 +797,27 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
748
797
|
} catch (_e) {
|
|
749
798
|
void _e;
|
|
750
799
|
}
|
|
800
|
+
} else if (prof?.defaultModel) {
|
|
801
|
+
// In Default mode, apply the profile's default model immediately.
|
|
802
|
+
try {
|
|
803
|
+
const m = prof.defaultModel;
|
|
804
|
+
const reg = (ctx as any)?.modelRegistry;
|
|
805
|
+
const found = reg?.find?.(m.provider, m.id);
|
|
806
|
+
if (found) {
|
|
807
|
+
await (pi as any).setModel?.(found);
|
|
808
|
+
if (m.thinking && isValidThinkingLevel(m.thinking)) {
|
|
809
|
+
(pi as any).setThinkingLevel?.(m.thinking);
|
|
810
|
+
}
|
|
811
|
+
if (!opts?.quiet) {
|
|
812
|
+
ctx.ui.notify(
|
|
813
|
+
`Default mode: model → ${m.provider}/${m.id}${m.thinking ? ` (${m.thinking})` : ""}`,
|
|
814
|
+
"info",
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
} catch (_e) {
|
|
819
|
+
void _e;
|
|
820
|
+
}
|
|
751
821
|
}
|
|
752
822
|
if (!opts?.quiet) {
|
|
753
823
|
ctx.ui.notify(
|
|
@@ -3420,6 +3490,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3420
3490
|
const m = getEffectiveRole(roleName)?.model;
|
|
3421
3491
|
if (m) seed.roles[roleName] = { ...m };
|
|
3422
3492
|
}
|
|
3493
|
+
if (defaultModel)
|
|
3494
|
+
seed.defaultModel = {
|
|
3495
|
+
provider: defaultModel.provider,
|
|
3496
|
+
id: defaultModel.id,
|
|
3497
|
+
thinking: (defaultModel.thinking as ThinkingLevel) ?? "medium",
|
|
3498
|
+
};
|
|
3423
3499
|
return seed;
|
|
3424
3500
|
}
|
|
3425
3501
|
|
|
@@ -3600,10 +3676,15 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3600
3676
|
]
|
|
3601
3677
|
.filter(Boolean)
|
|
3602
3678
|
.join(", ");
|
|
3603
|
-
const modelSummary =
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3679
|
+
const modelSummary = [
|
|
3680
|
+
...PROFILE_ROLES.map((r) => {
|
|
3681
|
+
const m = p.roles[r];
|
|
3682
|
+
return m ? `${roleIcon(r)} ${formatModelRef(m)}` : null;
|
|
3683
|
+
}),
|
|
3684
|
+
p.defaultModel
|
|
3685
|
+
? `◦ ${formatModelRef(p.defaultModel)}`
|
|
3686
|
+
: null,
|
|
3687
|
+
]
|
|
3607
3688
|
.filter(Boolean)
|
|
3608
3689
|
.join(" · ");
|
|
3609
3690
|
entries.push({
|
|
@@ -3996,8 +4077,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3996
4077
|
description: base?.description ?? `Role ${roleName}`,
|
|
3997
4078
|
});
|
|
3998
4079
|
}
|
|
4080
|
+
roleMeta.set("default", {
|
|
4081
|
+
name: "default",
|
|
4082
|
+
description: "Model used in Default mode (chat)",
|
|
4083
|
+
});
|
|
3999
4084
|
// Keep the narrow tuple type so profile.roles[roleName] typechecks.
|
|
4000
|
-
const tabIds = [...PROFILE_ROLES];
|
|
4085
|
+
const tabIds = [...PROFILE_ROLES, "default"];
|
|
4001
4086
|
// Stage from the profile (edit) or the current effective roles (create).
|
|
4002
4087
|
const staged = new Map<string, StagedProfileModel>();
|
|
4003
4088
|
for (const roleName of PROFILE_ROLES) {
|
|
@@ -4009,6 +4094,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4009
4094
|
thinking: src?.thinking ?? "medium",
|
|
4010
4095
|
});
|
|
4011
4096
|
}
|
|
4097
|
+
const defaultSrc = existing?.defaultModel ?? defaultModel;
|
|
4098
|
+
staged.set("default", {
|
|
4099
|
+
provider: defaultSrc?.provider ?? "",
|
|
4100
|
+
id: defaultSrc?.id ?? "",
|
|
4101
|
+
thinking: (defaultSrc?.thinking as ThinkingLevel) ?? "medium",
|
|
4102
|
+
});
|
|
4012
4103
|
|
|
4013
4104
|
overlayActive = true;
|
|
4014
4105
|
let saved: {
|
|
@@ -4178,7 +4269,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4178
4269
|
for (let i = 0; i < tabIds.length; i++) {
|
|
4179
4270
|
const roleName = tabIds[i];
|
|
4180
4271
|
const st = staged.get(roleName)!;
|
|
4181
|
-
const base =
|
|
4272
|
+
const base =
|
|
4273
|
+
roleName === "default"
|
|
4274
|
+
? existing?.defaultModel
|
|
4275
|
+
: existing?.roles[roleName as ProfileRoleName];
|
|
4182
4276
|
const dirty =
|
|
4183
4277
|
!base ||
|
|
4184
4278
|
`${st.provider}/${st.id}` !== `${base.provider}/${base.id}` ||
|
|
@@ -4355,6 +4449,16 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4355
4449
|
thinking: st.thinking,
|
|
4356
4450
|
};
|
|
4357
4451
|
}
|
|
4452
|
+
const defaultSt = saved.staged.get("default");
|
|
4453
|
+
if (defaultSt && defaultSt.provider && defaultSt.id) {
|
|
4454
|
+
profile.defaultModel = {
|
|
4455
|
+
provider: defaultSt.provider,
|
|
4456
|
+
id: defaultSt.id,
|
|
4457
|
+
thinking: defaultSt.thinking,
|
|
4458
|
+
};
|
|
4459
|
+
} else {
|
|
4460
|
+
delete profile.defaultModel;
|
|
4461
|
+
}
|
|
4358
4462
|
try {
|
|
4359
4463
|
saveProfiles(profilesConfig, "user");
|
|
4360
4464
|
} catch (e: any) {
|
|
@@ -6299,6 +6403,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6299
6403
|
// ── Session & Agent lifecycle ────────────────────────────────────
|
|
6300
6404
|
|
|
6301
6405
|
pi.on("session_start", async (event, ctx) => {
|
|
6406
|
+
// A reloaded/forked session must never inherit a stale write denial.
|
|
6407
|
+
writeGateRunDenials.clear();
|
|
6408
|
+
writeGateTurnAnswer = null;
|
|
6409
|
+
writeGateTurnSteered = false;
|
|
6410
|
+
writeGateBatchCalls = [];
|
|
6411
|
+
writeGateBatchCallIds = new Set();
|
|
6302
6412
|
// Roles are file-backed; load them up-front so every read path (status,
|
|
6303
6413
|
// band label, reviewer runtime) sees the real config even in a fresh
|
|
6304
6414
|
// session that has no persisted workflow entry yet. Profiles likewise.
|
|
@@ -6368,6 +6478,11 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6368
6478
|
void _e;
|
|
6369
6479
|
}
|
|
6370
6480
|
activeProfileName = null;
|
|
6481
|
+
} else if (activeProfileName) {
|
|
6482
|
+
const rp = getProfile(profilesConfig, activeProfileName);
|
|
6483
|
+
if (rp?.defaultModel) {
|
|
6484
|
+
defaultModel = { ...rp.defaultModel };
|
|
6485
|
+
}
|
|
6371
6486
|
}
|
|
6372
6487
|
} else {
|
|
6373
6488
|
activeProfileName = null;
|
|
@@ -6471,6 +6586,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6471
6586
|
activeProfileName = p.name;
|
|
6472
6587
|
invalidateEffectiveRoles();
|
|
6473
6588
|
}
|
|
6589
|
+
if (p.defaultModel) {
|
|
6590
|
+
defaultModel = { ...p.defaultModel };
|
|
6591
|
+
}
|
|
6474
6592
|
} else {
|
|
6475
6593
|
try {
|
|
6476
6594
|
(ctx as any).ui?.notify?.(
|
|
@@ -6544,6 +6662,12 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6544
6662
|
});
|
|
6545
6663
|
|
|
6546
6664
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
6665
|
+
// A write denial is run-scoped; the session is going away, so drop it.
|
|
6666
|
+
writeGateRunDenials.clear();
|
|
6667
|
+
writeGateTurnAnswer = null;
|
|
6668
|
+
writeGateTurnSteered = false;
|
|
6669
|
+
writeGateBatchCalls = [];
|
|
6670
|
+
writeGateBatchCallIds = new Set();
|
|
6547
6671
|
// Kill any active explore subagents to prevent orphan leaks.
|
|
6548
6672
|
// Without this, `pi --mode json` children spawned by runSingleAgent
|
|
6549
6673
|
// (up to 4 concurrent) could survive if VS Code closes the terminal
|
|
@@ -6745,6 +6869,88 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6745
6869
|
}
|
|
6746
6870
|
}
|
|
6747
6871
|
|
|
6872
|
+
/**
|
|
6873
|
+
* The gated writes of the current assistant turn (the batch), as items the
|
|
6874
|
+
* dialog can list.
|
|
6875
|
+
*
|
|
6876
|
+
* Pi runs an assistant message's tool calls as a batch and prompts the
|
|
6877
|
+
* extension for EVERY call before the first tool of the batch runs, so the
|
|
6878
|
+
* batch is the natural scope of ONE question. The current call is always
|
|
6879
|
+
* part of the list (its arguments may have been rewritten by another
|
|
6880
|
+
* extension's tool_call handler after the assistant message was sealed).
|
|
6881
|
+
*/
|
|
6882
|
+
function collectWriteGateItems(
|
|
6883
|
+
currentEvent: any,
|
|
6884
|
+
currentClassification: WriteCallClassification,
|
|
6885
|
+
config: WritePolicyConfig,
|
|
6886
|
+
cwd: string,
|
|
6887
|
+
): WriteGateItem[] {
|
|
6888
|
+
const items: WriteGateItem[] = [];
|
|
6889
|
+
const seen = new Set<string>();
|
|
6890
|
+
const add = (name: string, classification: WriteCallClassification) => {
|
|
6891
|
+
const item = writeGateItemFromClassification(name, classification, cwd);
|
|
6892
|
+
if (!item.key || seen.has(item.key)) return;
|
|
6893
|
+
seen.add(item.key);
|
|
6894
|
+
items.push(item);
|
|
6895
|
+
};
|
|
6896
|
+
for (const call of writeGateBatchCalls) {
|
|
6897
|
+
const name = String(call.name ?? "");
|
|
6898
|
+
if (!name) continue;
|
|
6899
|
+
const classification = classifyWriteCall(name, call.args, config);
|
|
6900
|
+
if (!classification.mutating) continue;
|
|
6901
|
+
// A granted target is not gated — it never reaches a dialog.
|
|
6902
|
+
if (isWriteCallAllowed(classification, config, cwd)) continue;
|
|
6903
|
+
// The plan directory stays writable in every mode.
|
|
6904
|
+
if (
|
|
6905
|
+
classification.paths.length > 0 &&
|
|
6906
|
+
classification.paths.every((p) => isPlanWritePath(p, cwd))
|
|
6907
|
+
) {
|
|
6908
|
+
continue;
|
|
6909
|
+
}
|
|
6910
|
+
add(name, classification);
|
|
6911
|
+
}
|
|
6912
|
+
add(String(currentEvent?.toolName ?? ""), currentClassification);
|
|
6913
|
+
return items;
|
|
6914
|
+
}
|
|
6915
|
+
|
|
6916
|
+
/**
|
|
6917
|
+
* Deliver the user's instruction to the model as a USER turn (a steer).
|
|
6918
|
+
*
|
|
6919
|
+
* At most once per assistant turn: the sentence is also quoted in every
|
|
6920
|
+
* blocked call's reason (the tool result), so a turn needs ONE user turn,
|
|
6921
|
+
* not one per blocked call.
|
|
6922
|
+
*/
|
|
6923
|
+
function deliverWriteInstruction(
|
|
6924
|
+
items: readonly WriteGateItem[],
|
|
6925
|
+
instruction: string,
|
|
6926
|
+
): void {
|
|
6927
|
+
if (!instruction || writeGateTurnSteered) return;
|
|
6928
|
+
// Guard: while a review round is active the extension's `input` handler
|
|
6929
|
+
// routes incoming user text to the REVIEWER, so steering here would
|
|
6930
|
+
// hand the instruction to the wrong agent. The tool result still carries
|
|
6931
|
+
// it, so skip the steer rather than misroute.
|
|
6932
|
+
const rt = reviewRuntime;
|
|
6933
|
+
const wouldRouteToReviewer = shouldRouteInputToReviewer({
|
|
6934
|
+
reviewEnabled: reviewEnabled(),
|
|
6935
|
+
reviewActive: rt?.isRoundActive() === true,
|
|
6936
|
+
hasReviewerSession: rt?.isAlive() === true,
|
|
6937
|
+
});
|
|
6938
|
+
if (!wouldRouteToReviewer) {
|
|
6939
|
+
try {
|
|
6940
|
+
(pi as any).sendUserMessage?.(
|
|
6941
|
+
buildWriteGuidanceMessage({
|
|
6942
|
+
target: writeGateTargetsLabel(items) || "the requested write",
|
|
6943
|
+
instruction,
|
|
6944
|
+
}),
|
|
6945
|
+
{ deliverAs: "steer" },
|
|
6946
|
+
);
|
|
6947
|
+
} catch (_e) {
|
|
6948
|
+
void _e;
|
|
6949
|
+
}
|
|
6950
|
+
}
|
|
6951
|
+
writeGateTurnSteered = true;
|
|
6952
|
+
}
|
|
6953
|
+
|
|
6748
6954
|
/**
|
|
6749
6955
|
* Write permission gate (workflow.writePolicy) — Default mode only.
|
|
6750
6956
|
*
|
|
@@ -6762,6 +6968,9 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6762
6968
|
* may. Never throws — a throwing tool_call handler blocks the tool as a
|
|
6763
6969
|
* fail-safe, so every failure path here fails CLOSED with an actionable
|
|
6764
6970
|
* reason (the reason is what the model reads).
|
|
6971
|
+
*
|
|
6972
|
+
* Under `ask` it asks ONE question per assistant turn (see the batch
|
|
6973
|
+
* helpers above) and remembers a denial for the rest of the run.
|
|
6765
6974
|
*/
|
|
6766
6975
|
async function writePolicyGate(
|
|
6767
6976
|
event: any,
|
|
@@ -6830,7 +7039,20 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6830
7039
|
};
|
|
6831
7040
|
}
|
|
6832
7041
|
|
|
6833
|
-
// decision === "ask" —
|
|
7042
|
+
// decision === "ask" — ONE question per assistant turn.
|
|
7043
|
+
//
|
|
7044
|
+
// Pi runs an assistant message's tool calls as a batch and prompts the
|
|
7045
|
+
// extension for EVERY call before the first tool of the batch runs
|
|
7046
|
+
// (pi-agent-core: executeToolCallsParallel → prepareToolCall →
|
|
7047
|
+
// beforeToolCall). Asking per call therefore asked the SAME question
|
|
7048
|
+
// twice when the model batched two writes to one path, and the
|
|
7049
|
+
// instruction typed into the first dialog could not reach the model
|
|
7050
|
+
// before the second one opened (a tool result is appended only after
|
|
7051
|
+
// the whole batch, and a steer lands only after the turn's tool calls
|
|
7052
|
+
// have run) — the user had to retype the same sentence.
|
|
7053
|
+
//
|
|
7054
|
+
// So: one dialog per turn, listing every write the answer covers, and
|
|
7055
|
+
// the answer applies to all of them.
|
|
6834
7056
|
const ui = ctx?.ui;
|
|
6835
7057
|
if (!ui || !ctx.hasUI) {
|
|
6836
7058
|
return {
|
|
@@ -6842,41 +7064,98 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6842
7064
|
};
|
|
6843
7065
|
}
|
|
6844
7066
|
|
|
6845
|
-
|
|
6846
|
-
|
|
7067
|
+
// The turn's gated writes (the current call at minimum).
|
|
7068
|
+
// A call that is NOT part of the cached batch belongs to a NEW batch
|
|
7069
|
+
// (a host that delivers no assistant message, or a nested tool call):
|
|
7070
|
+
// reset the turn's answer first — a stale answer must never carry
|
|
7071
|
+
// over, or the gate would stop asking altogether.
|
|
7072
|
+
const callId = String(event.toolCallId ?? "");
|
|
7073
|
+
if (!writeGateBatchCallIds.has(callId)) {
|
|
7074
|
+
writeGateTurnAnswer = null;
|
|
7075
|
+
writeGateTurnSteered = false;
|
|
7076
|
+
writeGateBatchCalls = [];
|
|
7077
|
+
writeGateBatchCallIds = new Set();
|
|
7078
|
+
}
|
|
7079
|
+
const items = collectWriteGateItems(event, classification, config, cwd);
|
|
7080
|
+
const currentItem = writeGateItemFromClassification(
|
|
7081
|
+
String(event.toolName ?? ""),
|
|
7082
|
+
classification,
|
|
7083
|
+
cwd,
|
|
7084
|
+
);
|
|
7085
|
+
|
|
7086
|
+
// (a) The user denied this target earlier in THIS RUN → deny again,
|
|
7087
|
+
// silently. The model gets the same sentence; the user is not asked a
|
|
7088
|
+
// second time for the same decision.
|
|
7089
|
+
const remembered = writeGateRunDenials.get(currentItem.key);
|
|
7090
|
+
if (remembered) {
|
|
7091
|
+
deliverWriteInstruction(items, remembered.instruction);
|
|
7092
|
+
return {
|
|
7093
|
+
block: true,
|
|
7094
|
+
reason: buildWriteDenyReason({
|
|
7095
|
+
target,
|
|
7096
|
+
why: classification.reason,
|
|
7097
|
+
instruction: remembered.instruction,
|
|
7098
|
+
}),
|
|
7099
|
+
};
|
|
7100
|
+
}
|
|
7101
|
+
|
|
7102
|
+
// (b) The user already answered for THIS TURN → apply the answer to
|
|
7103
|
+
// every gated call of the turn. No second dialog.
|
|
7104
|
+
if (writeGateTurnAnswer) {
|
|
7105
|
+
const answer = writeGateTurnAnswer;
|
|
7106
|
+
if (answer.choice === "allow-once" || answer.choice === "always") {
|
|
7107
|
+
return undefined;
|
|
7108
|
+
}
|
|
7109
|
+
return {
|
|
7110
|
+
block: true,
|
|
7111
|
+
reason: buildWriteDenyReason({
|
|
7112
|
+
target,
|
|
7113
|
+
why: classification.reason,
|
|
7114
|
+
instruction: answer.instruction,
|
|
7115
|
+
}),
|
|
7116
|
+
};
|
|
7117
|
+
}
|
|
7118
|
+
|
|
7119
|
+
// (c) The first gated call of the turn → ask ONCE.
|
|
7120
|
+
const options = writeGateOptions(writeGateItemsKind(items));
|
|
6847
7121
|
let sel: unknown;
|
|
6848
7122
|
try {
|
|
6849
|
-
sel = await ui.select(
|
|
6850
|
-
`Write policy 'ask': allow this write${why}?`,
|
|
6851
|
-
options,
|
|
6852
|
-
);
|
|
7123
|
+
sel = await ui.select(writeGateTitle(items), options);
|
|
6853
7124
|
} catch (_e) {
|
|
6854
7125
|
void _e;
|
|
6855
7126
|
sel = undefined;
|
|
6856
7127
|
}
|
|
6857
7128
|
const choice = parseWriteGateChoice(sel);
|
|
6858
|
-
if (choice === "allow-once")
|
|
7129
|
+
if (choice === "allow-once") {
|
|
7130
|
+
// Applies to every gated write of this turn — the dialog listed them.
|
|
7131
|
+
writeGateTurnAnswer = { choice, instruction: "" };
|
|
7132
|
+
return undefined;
|
|
7133
|
+
}
|
|
6859
7134
|
|
|
6860
7135
|
if (choice === "always") {
|
|
6861
|
-
// Persist
|
|
6862
|
-
// stores the absolute path; a command
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
const written = writeWritePolicyConfig("global", cwd, getAgentDir(),
|
|
7136
|
+
// Persist a grant for EVERY write the dialog listed, so none of them
|
|
7137
|
+
// asks again. A path grant stores the absolute path; a command (or
|
|
7138
|
+
// path-less tool) grant stores the command key.
|
|
7139
|
+
const allowedPaths: string[] = [];
|
|
7140
|
+
const allowedCommands: string[] = [];
|
|
7141
|
+
for (const item of items) {
|
|
7142
|
+
for (const p of item.grantPaths ?? []) allowedPaths.push(p);
|
|
7143
|
+
if (item.grantCommand) allowedCommands.push(item.grantCommand);
|
|
7144
|
+
}
|
|
7145
|
+
const written = writeWritePolicyConfig("global", cwd, getAgentDir(), {
|
|
7146
|
+
allowedPaths,
|
|
7147
|
+
allowedCommands,
|
|
7148
|
+
});
|
|
6871
7149
|
if (written.ok) {
|
|
6872
7150
|
try {
|
|
6873
7151
|
(ctx as any).ui?.notify?.(
|
|
6874
|
-
`Write policy: always allowed ${
|
|
7152
|
+
`Write policy: always allowed ${writeGateTargetsLabel(items)}`,
|
|
6875
7153
|
"info",
|
|
6876
7154
|
);
|
|
6877
7155
|
} catch (_e) {
|
|
6878
7156
|
void _e;
|
|
6879
7157
|
}
|
|
7158
|
+
writeGateTurnAnswer = { choice, instruction: "" };
|
|
6880
7159
|
return undefined;
|
|
6881
7160
|
}
|
|
6882
7161
|
// The grant could not be persisted — do not silently allow a write
|
|
@@ -6921,8 +7200,17 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6921
7200
|
// below. A cancel is never an implicit allow.
|
|
6922
7201
|
}
|
|
6923
7202
|
|
|
7203
|
+
// The answer covers every gated write of this turn.
|
|
7204
|
+
writeGateTurnAnswer = { choice, instruction };
|
|
7205
|
+
// …and the denial is remembered for the REST OF THE RUN, under every
|
|
7206
|
+
// target of this turn: a later retry is auto-denied with the same
|
|
7207
|
+
// sentence instead of asking the user again.
|
|
7208
|
+
for (const item of items) {
|
|
7209
|
+
writeGateRunDenials.set(item.key, { instruction });
|
|
7210
|
+
}
|
|
7211
|
+
|
|
6924
7212
|
const reason = buildWriteDenyReason({
|
|
6925
|
-
target,
|
|
7213
|
+
target: items.length > 1 ? writeGateTargetsLabel(items) : target,
|
|
6926
7214
|
why: classification.reason,
|
|
6927
7215
|
instruction,
|
|
6928
7216
|
});
|
|
@@ -6930,35 +7218,19 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6930
7218
|
if (instruction) {
|
|
6931
7219
|
// Channel 2: a user-role message. `steer` queues it while the agent
|
|
6932
7220
|
// is running; it lands after the current tool batch, before the next
|
|
6933
|
-
// model call, as a genuine user turn.
|
|
6934
|
-
//
|
|
6935
|
-
|
|
6936
|
-
//
|
|
6937
|
-
//
|
|
6938
|
-
//
|
|
6939
|
-
const rt = reviewRuntime;
|
|
6940
|
-
const wouldRouteToReviewer = shouldRouteInputToReviewer({
|
|
6941
|
-
reviewEnabled: reviewEnabled(),
|
|
6942
|
-
reviewActive: rt?.isRoundActive() === true,
|
|
6943
|
-
hasReviewerSession: rt?.isAlive() === true,
|
|
6944
|
-
});
|
|
6945
|
-
if (!wouldRouteToReviewer) {
|
|
6946
|
-
try {
|
|
6947
|
-
(pi as any).sendUserMessage?.(
|
|
6948
|
-
buildWriteGuidanceMessage({ target, instruction }),
|
|
6949
|
-
{ deliverAs: "steer" },
|
|
6950
|
-
);
|
|
6951
|
-
} catch (_e) {
|
|
6952
|
-
void _e;
|
|
6953
|
-
}
|
|
6954
|
-
}
|
|
6955
|
-
// Audit trail — transcript only (appendEntry is never sent to the LLM).
|
|
7221
|
+
// model call, as a genuine user turn. ONE per turn, not one per
|
|
7222
|
+
// blocked call.
|
|
7223
|
+
deliverWriteInstruction(items, instruction);
|
|
7224
|
+
// Audit trail — transcript only (appendEntry is never sent to the
|
|
7225
|
+
// LLM). One entry per turn: it records the user's decision, not each
|
|
7226
|
+
// call the decision was applied to.
|
|
6956
7227
|
try {
|
|
6957
7228
|
pi.appendEntry("workflow-write-denial", {
|
|
6958
7229
|
tool: String(event.toolName ?? ""),
|
|
6959
7230
|
target,
|
|
6960
7231
|
instruction,
|
|
6961
7232
|
at: Date.now(),
|
|
7233
|
+
targets: items.map((item) => item.target),
|
|
6962
7234
|
});
|
|
6963
7235
|
} catch (_e) {
|
|
6964
7236
|
void _e;
|
|
@@ -7274,8 +7546,45 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
7274
7546
|
}
|
|
7275
7547
|
});
|
|
7276
7548
|
|
|
7549
|
+
// ── Write-policy gate: the turn's batch ──
|
|
7550
|
+
// Pi prepares and prompts EVERY tool call of an assistant message before
|
|
7551
|
+
// the first tool of that message runs (pi-agent-core's
|
|
7552
|
+
// executeToolCallsParallel → prepareToolCall → beforeToolCall). The tool
|
|
7553
|
+
// calls of the assistant message ARE the turn's batch, so the gate can ask
|
|
7554
|
+
// ONE question that lists every write the answer covers. A new assistant
|
|
7555
|
+
// message is a new batch — hence a new turn, hence a new answer.
|
|
7556
|
+
pi.on("message_end", async (event) => {
|
|
7557
|
+
try {
|
|
7558
|
+
const message = (event as any)?.message;
|
|
7559
|
+
if (!message || message.role !== "assistant") return;
|
|
7560
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
7561
|
+
const calls: { name: string; args: unknown }[] = [];
|
|
7562
|
+
const callIds = new Set<string>();
|
|
7563
|
+
for (const block of content) {
|
|
7564
|
+
if (!block || block.type !== "toolCall") continue;
|
|
7565
|
+
calls.push({
|
|
7566
|
+
name: String(block.name ?? ""),
|
|
7567
|
+
args: block.arguments,
|
|
7568
|
+
});
|
|
7569
|
+
if (block.id) callIds.add(String(block.id));
|
|
7570
|
+
}
|
|
7571
|
+
writeGateBatchCalls = calls;
|
|
7572
|
+
writeGateBatchCallIds = callIds;
|
|
7573
|
+
writeGateTurnAnswer = null;
|
|
7574
|
+
writeGateTurnSteered = false;
|
|
7575
|
+
} catch (_e) {
|
|
7576
|
+
void _e;
|
|
7577
|
+
}
|
|
7578
|
+
});
|
|
7579
|
+
|
|
7277
7580
|
pi.on("turn_start", async (_event, ctx) => {
|
|
7278
7581
|
footerInjectedThisTurn = false;
|
|
7582
|
+
// A new turn = a new answer for the write gate (the batch's own
|
|
7583
|
+
// message_end already resets it; this is the defensive second guard).
|
|
7584
|
+
writeGateTurnAnswer = null;
|
|
7585
|
+
writeGateTurnSteered = false;
|
|
7586
|
+
writeGateBatchCalls = [];
|
|
7587
|
+
writeGateBatchCallIds = new Set();
|
|
7279
7588
|
// Seed the per-step commit ledger once per session: steps already done
|
|
7280
7589
|
// (restored sessions, re-executed plans) never trigger a spurious commit.
|
|
7281
7590
|
try {
|
|
@@ -7318,6 +7627,14 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
7318
7627
|
// dropped here — nothing that runs next inherits them.
|
|
7319
7628
|
commitAuthorization = null;
|
|
7320
7629
|
pendingCommitFlow = null;
|
|
7630
|
+
// A write denial is remembered for the REST OF THE RUN, so a later retry
|
|
7631
|
+
// of the same target is auto-denied instead of asking again. The run is
|
|
7632
|
+
// over → the next run asks again.
|
|
7633
|
+
writeGateRunDenials.clear();
|
|
7634
|
+
writeGateTurnAnswer = null;
|
|
7635
|
+
writeGateTurnSteered = false;
|
|
7636
|
+
writeGateBatchCalls = [];
|
|
7637
|
+
writeGateBatchCallIds = new Set();
|
|
7321
7638
|
// keep persisted CustomEntry for reload, clear in-memory tombstone only on explicit prune; for now no clear to survive fork
|
|
7322
7639
|
});
|
|
7323
7640
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dieulc/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node --test utils.todo.test.ts permissions.test.ts roles.test.ts profiles.test.ts review.test.ts review-ui.test.ts commit-policy.test.ts edit-policy.test.ts",
|
|
11
|
-
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
11
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
12
|
+
"check:policy": "node --experimental-transform-types scripts/verify-write-policy.mjs"
|
|
12
13
|
},
|
|
13
14
|
"repository": {
|
|
14
15
|
"type": "git",
|
package/profiles.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface Profile {
|
|
|
55
55
|
description: string;
|
|
56
56
|
/** Partial by design: hand-edited files may override a subset of roles. */
|
|
57
57
|
roles: Partial<Record<ProfileRoleName, RoleModel>>;
|
|
58
|
+
/** Optional model used in Default mode (chat). Not a role — roles.json is untouched. */
|
|
59
|
+
defaultModel?: RoleModel;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
export interface ProfileConfig {
|
|
@@ -240,11 +242,17 @@ export function sanitizeProfile(p: any): Profile | undefined {
|
|
|
240
242
|
const m = sanitizeProfileModel((rawRoles as any)[roleName]);
|
|
241
243
|
if (m) roles[roleName] = m;
|
|
242
244
|
}
|
|
243
|
-
|
|
245
|
+
// Default-mode model: prefer the dedicated field, fall back to a
|
|
246
|
+
// `roles.default` alias for hand-edited files.
|
|
247
|
+
const defaultModel =
|
|
248
|
+
sanitizeProfileModel(p.defaultModel) ??
|
|
249
|
+
sanitizeProfileModel((rawRoles as any).default);
|
|
250
|
+
if (Object.keys(roles).length === 0 && !defaultModel) return undefined;
|
|
244
251
|
return {
|
|
245
252
|
name: nameCheck.value,
|
|
246
253
|
description: typeof p.description === "string" ? p.description.trim() : "",
|
|
247
254
|
roles,
|
|
255
|
+
...(defaultModel ? { defaultModel } : {}),
|
|
248
256
|
};
|
|
249
257
|
}
|
|
250
258
|
|
|
@@ -417,6 +425,10 @@ export function formatProfileOneLine(p: Profile): string {
|
|
|
417
425
|
const m = p.roles[r];
|
|
418
426
|
return m ? `${roleIcon(r)} ${formatModelRef(m)} (${m.thinking})` : `${roleIcon(r)} —`;
|
|
419
427
|
});
|
|
428
|
+
if (p.defaultModel) {
|
|
429
|
+
const m = p.defaultModel;
|
|
430
|
+
parts.push(`◦ ${formatModelRef(m)} (${m.thinking})`);
|
|
431
|
+
}
|
|
420
432
|
const desc = p.description ? ` — ${p.description}` : "";
|
|
421
433
|
return `⚡ ${p.name}${desc}: ${parts.join(" · ")}`;
|
|
422
434
|
}
|
|
@@ -433,6 +445,12 @@ export function describeProfile(p: Profile): string {
|
|
|
433
445
|
}`,
|
|
434
446
|
);
|
|
435
447
|
}
|
|
448
|
+
const dm = p.defaultModel;
|
|
449
|
+
lines.push(
|
|
450
|
+
` ◦ default: ${
|
|
451
|
+
dm ? `${formatModelRef(dm)} (${dm.thinking})` : "(not set — keeps current Default-mode model)"
|
|
452
|
+
}`,
|
|
453
|
+
);
|
|
436
454
|
return lines.join("\n");
|
|
437
455
|
}
|
|
438
456
|
|
|
@@ -473,6 +491,12 @@ export function formatProfilesForDisplay(
|
|
|
473
491
|
}`,
|
|
474
492
|
);
|
|
475
493
|
}
|
|
494
|
+
const dm = p.defaultModel;
|
|
495
|
+
lines.push(
|
|
496
|
+
` ◦ default: ${
|
|
497
|
+
dm ? `${formatModelRef(dm)} (${dm.thinking})` : "(current Default-mode model)"
|
|
498
|
+
}`,
|
|
499
|
+
);
|
|
476
500
|
}
|
|
477
501
|
lines.push("");
|
|
478
502
|
lines.push(
|