@dieulc/workflow 0.5.1 → 0.6.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 +100 -3
- package/commit-policy.ts +103 -0
- package/edit-policy.ts +955 -0
- package/index.ts +429 -115
- package/package.json +3 -2
- package/permissions.ts +25 -5
- package/profiles.ts +208 -1
- package/roles.ts +142 -0
- package/utils.ts +60 -0
package/index.ts
CHANGED
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
COMMIT_FLOW_TTL_MS,
|
|
95
95
|
armCommitAuthorization,
|
|
96
96
|
commitConfigPaths,
|
|
97
|
+
commitPolicyArgumentCompletions,
|
|
97
98
|
decideCommitGate,
|
|
98
99
|
describeCommitPolicyMode,
|
|
99
100
|
isAuthorizationActive,
|
|
@@ -109,6 +110,25 @@ import {
|
|
|
109
110
|
type PendingCommitFlow,
|
|
110
111
|
type StepCommitCandidate,
|
|
111
112
|
} from "./commit-policy.ts";
|
|
113
|
+
import {
|
|
114
|
+
buildWriteDenyReason,
|
|
115
|
+
buildWriteGuidanceMessage,
|
|
116
|
+
classifyWriteCall,
|
|
117
|
+
decideWriteGate,
|
|
118
|
+
describeWritePolicyMode,
|
|
119
|
+
isWriteCallAllowed,
|
|
120
|
+
normalizeWritePath,
|
|
121
|
+
parseWriteGateChoice,
|
|
122
|
+
readWritePolicyConfig,
|
|
123
|
+
sanitizeWriteInstruction,
|
|
124
|
+
writePolicyArgumentCompletions,
|
|
125
|
+
writePolicyConfigPaths,
|
|
126
|
+
writeWritePolicyConfig,
|
|
127
|
+
writeGateOptions,
|
|
128
|
+
WRITE_INSTRUCTION_PROMPT_PLACEHOLDER,
|
|
129
|
+
WRITE_INSTRUCTION_PROMPT_TITLE,
|
|
130
|
+
type WritePolicyMode,
|
|
131
|
+
} from "./edit-policy.ts";
|
|
112
132
|
import {
|
|
113
133
|
isGitRepo,
|
|
114
134
|
isGitRepoSync,
|
|
@@ -122,6 +142,7 @@ import {
|
|
|
122
142
|
defaultRoleConfig,
|
|
123
143
|
loadRoleConfigDetailed,
|
|
124
144
|
rolesFingerprint,
|
|
145
|
+
roleArgumentCompletions,
|
|
125
146
|
saveRoleConfig,
|
|
126
147
|
getRole,
|
|
127
148
|
formatRolesForDisplay,
|
|
@@ -145,6 +166,7 @@ import {
|
|
|
145
166
|
getProfile,
|
|
146
167
|
formatProfilesForDisplay,
|
|
147
168
|
describeProfile,
|
|
169
|
+
profileArgumentCompletions,
|
|
148
170
|
validateProfileName,
|
|
149
171
|
applyProfileOverlay,
|
|
150
172
|
missingProfileRoles,
|
|
@@ -905,14 +927,29 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
905
927
|
commitMode = "never";
|
|
906
928
|
}
|
|
907
929
|
const policy = ` · commits:${commitMode}`;
|
|
930
|
+
// Write-policy surfacing (workflow.writePolicy): the active file-write
|
|
931
|
+
// permission, visible at a glance like the commit policy. Read live so a
|
|
932
|
+
// /write-policy change shows up without a reload. It only applies in
|
|
933
|
+
// Default mode, so it is omitted in the other modes.
|
|
934
|
+
let writeMode: WritePolicyMode = "allow";
|
|
935
|
+
try {
|
|
936
|
+
writeMode = readWritePolicyConfig(
|
|
937
|
+
(ctx as any).cwd ?? process.cwd(),
|
|
938
|
+
getAgentDir(),
|
|
939
|
+
).config.mode;
|
|
940
|
+
} catch {
|
|
941
|
+
writeMode = "allow";
|
|
942
|
+
}
|
|
943
|
+
const writePolicy =
|
|
944
|
+
workflowMode === null ? ` · writes:${writeMode}` : "";
|
|
908
945
|
const staleMarker = todoStale ? " ⚠" : "";
|
|
909
946
|
const done = todoItems.filter((t) => isDone(t)).length;
|
|
910
947
|
const total = todoItems.length;
|
|
911
948
|
if (workflowMode === "build") {
|
|
912
949
|
const label =
|
|
913
950
|
total > 0
|
|
914
|
-
? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}`
|
|
915
|
-
: `▶ build${suffix}${policy}`;
|
|
951
|
+
? `▶ build ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}`
|
|
952
|
+
: `▶ build${suffix}${policy}${writePolicy}`;
|
|
916
953
|
ctx.ui.setStatus("workflow", ctx.ui.theme.fg("accent", label));
|
|
917
954
|
} else if (workflowMode === "plan") {
|
|
918
955
|
const base = plannotatorActive
|
|
@@ -920,21 +957,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
920
957
|
: "⏸ plan";
|
|
921
958
|
const label =
|
|
922
959
|
total > 0
|
|
923
|
-
? `${base} ${done}/${total}${staleMarker}${suffix}${policy}`
|
|
924
|
-
: `${base}${suffix}${policy}`;
|
|
960
|
+
? `${base} ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}`
|
|
961
|
+
: `${base}${suffix}${policy}${writePolicy}`;
|
|
925
962
|
ctx.ui.setStatus("workflow", ctx.ui.theme.fg("warning", label));
|
|
926
963
|
} else if (total > 0) {
|
|
927
964
|
ctx.ui.setStatus(
|
|
928
965
|
"workflow",
|
|
929
966
|
ctx.ui.theme.fg(
|
|
930
967
|
"muted",
|
|
931
|
-
`◦ ${done}/${total}${staleMarker}${suffix}${policy}`,
|
|
968
|
+
`◦ ${done}/${total}${staleMarker}${suffix}${policy}${writePolicy}`,
|
|
932
969
|
),
|
|
933
970
|
);
|
|
934
971
|
} else {
|
|
935
972
|
ctx.ui.setStatus(
|
|
936
973
|
"workflow",
|
|
937
|
-
ctx.ui.theme.fg("muted", `◦ commits:${commitMode}`),
|
|
974
|
+
ctx.ui.theme.fg("muted", `◦ commits:${commitMode}${writePolicy}`),
|
|
938
975
|
);
|
|
939
976
|
}
|
|
940
977
|
if (todoItems.length > 0) {
|
|
@@ -1587,39 +1624,108 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
1587
1624
|
);
|
|
1588
1625
|
updateStatus(ctx as any);
|
|
1589
1626
|
},
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1627
|
+
// pi-tui full-argument contract (see commitPolicyArgumentCompletions):
|
|
1628
|
+
// modes first, then the write scope, with values that replace the whole
|
|
1629
|
+
// typed argument text ("ask project", never bare "project").
|
|
1630
|
+
getArgumentCompletions: (prefix: string) =>
|
|
1631
|
+
commitPolicyArgumentCompletions(prefix),
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
// ── /write-policy: view/set the write permission (workflow.writePolicy) ──
|
|
1635
|
+
// Available in every mode: it only writes settings JSON; the gate itself
|
|
1636
|
+
// lives in tool_call and applies in Default mode only.
|
|
1637
|
+
pi.registerCommand("write-policy", {
|
|
1638
|
+
description:
|
|
1639
|
+
"View or set the write permission (what the agent may modify in Default mode): /write-policy allow|ask|never [global|project]",
|
|
1640
|
+
handler: async (args, ctx) => {
|
|
1641
|
+
const cwd = (ctx as any)?.cwd ?? process.cwd();
|
|
1642
|
+
const agentDir = getAgentDir();
|
|
1643
|
+
const tokens = String(args || "")
|
|
1644
|
+
.trim()
|
|
1645
|
+
.split(/\s+/)
|
|
1646
|
+
.filter(Boolean);
|
|
1647
|
+
const sub = (tokens[0] || "").toLowerCase();
|
|
1648
|
+
const scope: "global" | "project" =
|
|
1649
|
+
tokens.includes("project") && !tokens.includes("global")
|
|
1650
|
+
? "project"
|
|
1651
|
+
: "global";
|
|
1652
|
+
|
|
1653
|
+
if (!sub || sub === "status") {
|
|
1654
|
+
const resolved = readWritePolicyConfig(cwd, agentDir);
|
|
1655
|
+
const paths = writePolicyConfigPaths(cwd, agentDir);
|
|
1656
|
+
const where =
|
|
1657
|
+
resolved.sources.mode === "default"
|
|
1658
|
+
? "default — no settings file supplies it"
|
|
1659
|
+
: `${resolved.sources.mode} settings`;
|
|
1660
|
+
const lines = [
|
|
1661
|
+
`Write policy: ${resolved.config.mode} (${where})`,
|
|
1662
|
+
` ${describeWritePolicyMode(resolved.config.mode)}`,
|
|
1663
|
+
" Applies to Default mode only — Plan mode is read-only already, /build is the execution escape hatch.",
|
|
1664
|
+
];
|
|
1665
|
+
if (resolved.config.allowedPaths.length > 0) {
|
|
1666
|
+
lines.push(
|
|
1667
|
+
`Always-allowed paths (${resolved.config.allowedPaths.length}):`,
|
|
1668
|
+
...resolved.config.allowedPaths.map((p) => ` ${p}`),
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
if (resolved.config.allowedCommands.length > 0) {
|
|
1672
|
+
lines.push(
|
|
1673
|
+
`Always-allowed commands (${resolved.config.allowedCommands.length}):`,
|
|
1674
|
+
...resolved.config.allowedCommands.map((c) => ` ${c}`),
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
if (resolved.config.mutatingTools.length > 0) {
|
|
1678
|
+
lines.push(
|
|
1679
|
+
`Extra write tools (${resolved.config.mutatingTools.length}):`,
|
|
1680
|
+
...resolved.config.mutatingTools.map((t) => ` ${t}`),
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
lines.push(
|
|
1684
|
+
`Global settings: ${paths.global}`,
|
|
1685
|
+
`Project settings: ${paths.project}`,
|
|
1686
|
+
"Usage: /write-policy allow|ask|never [global|project]",
|
|
1687
|
+
);
|
|
1688
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
1689
|
+
updateStatus(ctx as any);
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
if (sub !== "allow" && sub !== "ask" && sub !== "never") {
|
|
1694
|
+
ctx.ui.notify(
|
|
1695
|
+
[
|
|
1696
|
+
"Usage: /write-policy allow|ask|never [global|project]",
|
|
1697
|
+
" allow the agent may write files freely in Default mode",
|
|
1698
|
+
" ask every file write asks first; granted paths/commands are remembered",
|
|
1699
|
+
" never the agent may not write files — you write, or switch to /build",
|
|
1700
|
+
].join("\n"),
|
|
1701
|
+
"info",
|
|
1702
|
+
);
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const res = writeWritePolicyConfig(scope, cwd, agentDir, {
|
|
1707
|
+
mode: sub as WritePolicyMode,
|
|
1708
|
+
});
|
|
1709
|
+
if (!res.ok) {
|
|
1710
|
+
ctx.ui.notify(
|
|
1711
|
+
`Could not write write-policy settings: ${res.error ?? "unknown error"}`,
|
|
1712
|
+
"error",
|
|
1713
|
+
);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
ctx.ui.notify(
|
|
1717
|
+
`Write policy → ${sub} (${scope})\n${describeWritePolicyMode(
|
|
1718
|
+
sub as WritePolicyMode,
|
|
1719
|
+
)}\nSettings: ${res.path}`,
|
|
1720
|
+
"info",
|
|
1721
|
+
);
|
|
1722
|
+
updateStatus(ctx as any);
|
|
1622
1723
|
},
|
|
1724
|
+
// pi-tui full-argument contract (see writePolicyArgumentCompletions):
|
|
1725
|
+
// modes first, then the write scope, with values that replace the whole
|
|
1726
|
+
// typed argument text ("ask project", never bare "project").
|
|
1727
|
+
getArgumentCompletions: (prefix: string) =>
|
|
1728
|
+
writePolicyArgumentCompletions(prefix),
|
|
1623
1729
|
});
|
|
1624
1730
|
|
|
1625
1731
|
pi.registerCommand("review-prompt", {
|
|
@@ -3042,6 +3148,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
3042
3148
|
|
|
3043
3149
|
pi.registerCommand("role", {
|
|
3044
3150
|
description: "Configure one model + thinking per role — opens picker UI",
|
|
3151
|
+
// pi-tui full-argument contract (see roleArgumentCompletions): positional
|
|
3152
|
+
// set/use/remove completions whose values replace the whole typed
|
|
3153
|
+
// argument text ("set planner <provider/model>"), never bare tokens.
|
|
3154
|
+
getArgumentCompletions: (prefix: string) => {
|
|
3155
|
+
// No ctx here — use the live session cwd so project-scope roles.json is
|
|
3156
|
+
// honored, matching the handler's ensureRoleConfig(cwd) key.
|
|
3157
|
+
ensureRoleConfig((currentSessionCtx as any)?.cwd);
|
|
3158
|
+
const models = collectRoleCatalog(currentSessionCtx as any).all.map(
|
|
3159
|
+
(e) => formatModelRef({ provider: e.provider, id: e.id } as RoleModel),
|
|
3160
|
+
);
|
|
3161
|
+
return roleArgumentCompletions(prefix, {
|
|
3162
|
+
roleNames: roleConfig.roles.map((r) => r.name),
|
|
3163
|
+
models,
|
|
3164
|
+
});
|
|
3165
|
+
},
|
|
3045
3166
|
handler: async (args, ctx) => {
|
|
3046
3167
|
const cwd = (ctx as any)?.cwd as string | undefined;
|
|
3047
3168
|
ensureRoleConfig(cwd);
|
|
@@ -4553,90 +4674,31 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
4553
4674
|
return;
|
|
4554
4675
|
}
|
|
4555
4676
|
|
|
4556
|
-
// Bare profile name → show detail (mirrors /role <name>).
|
|
4677
|
+
// Bare profile name → show detail (mirrors /role <name>). The hint
|
|
4678
|
+
// matters: this branch is where the old completion bug dropped users
|
|
4679
|
+
// who meant `/profile use <name>`.
|
|
4557
4680
|
const named = getProfile(profilesConfig, sub);
|
|
4558
4681
|
if (named) {
|
|
4559
|
-
ctx.ui.notify(
|
|
4682
|
+
ctx.ui.notify(
|
|
4683
|
+
`${describeProfile(named)}\n\nActivate it with /profile use ${named.name}.`,
|
|
4684
|
+
"info",
|
|
4685
|
+
);
|
|
4560
4686
|
return;
|
|
4561
4687
|
}
|
|
4562
4688
|
ctx.ui.notify(PROFILE_USAGE, "info");
|
|
4563
4689
|
},
|
|
4690
|
+
// pi-tui contract: argument completions replace the ENTIRE typed argument
|
|
4691
|
+
// text with item.value (see profileArgumentCompletions / utils.joinArgument
|
|
4692
|
+
// Value). The old inline completer returned bare profile names, so
|
|
4693
|
+
// accepting a suggestion rewrote "/profile use X" → "/profile X" and the
|
|
4694
|
+
// command silently showed details instead of activating.
|
|
4564
4695
|
getArgumentCompletions: (prefix: string) => {
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
description: "activate/deactivate for this session",
|
|
4572
|
-
},
|
|
4573
|
-
{
|
|
4574
|
-
value: "create",
|
|
4575
|
-
label: "create <name>",
|
|
4576
|
-
description: "create a profile (seeded from current models)",
|
|
4577
|
-
},
|
|
4578
|
-
{
|
|
4579
|
-
value: "edit",
|
|
4580
|
-
label: "edit <name>",
|
|
4581
|
-
description: "open the tabbed model editor",
|
|
4582
|
-
},
|
|
4583
|
-
{
|
|
4584
|
-
value: "rename",
|
|
4585
|
-
label: "rename <old> <new>",
|
|
4586
|
-
description: "rename a profile",
|
|
4587
|
-
},
|
|
4588
|
-
{
|
|
4589
|
-
value: "delete",
|
|
4590
|
-
label: "delete <name>",
|
|
4591
|
-
description: "delete a profile",
|
|
4592
|
-
},
|
|
4593
|
-
{
|
|
4594
|
-
value: "default",
|
|
4595
|
-
label: "default [name|none] [global|project]",
|
|
4596
|
-
description: "default profile for new sessions",
|
|
4597
|
-
},
|
|
4598
|
-
{
|
|
4599
|
-
value: "show",
|
|
4600
|
-
label: "show <name>",
|
|
4601
|
-
description: "show one profile's models",
|
|
4602
|
-
},
|
|
4603
|
-
];
|
|
4604
|
-
const lower = String(prefix || "").toLowerCase();
|
|
4605
|
-
if (lower.includes(" ")) {
|
|
4606
|
-
// Completing an argument after a subcommand → profile names where relevant.
|
|
4607
|
-
const cmd = lower.trim().split(/\s+/)[0];
|
|
4608
|
-
const wantsNames =
|
|
4609
|
-
cmd === "use" ||
|
|
4610
|
-
cmd === "edit" ||
|
|
4611
|
-
cmd === "delete" ||
|
|
4612
|
-
cmd === "show" ||
|
|
4613
|
-
cmd === "default";
|
|
4614
|
-
if (!wantsNames) return null;
|
|
4615
|
-
const last = lower.trim().split(/\s+/).pop() ?? "";
|
|
4616
|
-
const names = profilesConfig.profiles
|
|
4617
|
-
.filter((p) => p.name.toLowerCase().startsWith(last))
|
|
4618
|
-
.map((p) => ({
|
|
4619
|
-
value: p.name,
|
|
4620
|
-
label: p.name,
|
|
4621
|
-
description: p.description || undefined,
|
|
4622
|
-
}));
|
|
4623
|
-
return cmd === "use" || cmd === "default"
|
|
4624
|
-
? [
|
|
4625
|
-
...names,
|
|
4626
|
-
{ value: "none", label: "none", description: "no profile" },
|
|
4627
|
-
]
|
|
4628
|
-
: names;
|
|
4629
|
-
}
|
|
4630
|
-
const matching = subs.filter((s) => s.value.startsWith(lower));
|
|
4631
|
-
if (matching.length > 0) return matching;
|
|
4632
|
-
const names = profilesConfig.profiles
|
|
4633
|
-
.filter((p) => p.name.toLowerCase().startsWith(lower))
|
|
4634
|
-
.map((p) => ({
|
|
4635
|
-
value: p.name,
|
|
4636
|
-
label: p.name,
|
|
4637
|
-
description: p.description || undefined,
|
|
4638
|
-
}));
|
|
4639
|
-
return names.length ? names : null;
|
|
4696
|
+
// The completion callback gets no ctx — use the live session cwd so
|
|
4697
|
+
// project-scope profiles (<cwd>/.pi/profiles.json) are included and the
|
|
4698
|
+
// profiles cache key matches the handler's (a cwd-less reload could
|
|
4699
|
+
// otherwise deactivate an active project-scope profile).
|
|
4700
|
+
ensureProfilesConfig((currentSessionCtx as any)?.cwd);
|
|
4701
|
+
return profileArgumentCompletions(prefix, profilesConfig.profiles);
|
|
4640
4702
|
},
|
|
4641
4703
|
});
|
|
4642
4704
|
|
|
@@ -6657,6 +6719,249 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6657
6719
|
}
|
|
6658
6720
|
}
|
|
6659
6721
|
|
|
6722
|
+
/**
|
|
6723
|
+
* Write permission gate (workflow.writePolicy) — Default mode only.
|
|
6724
|
+
*
|
|
6725
|
+
* The Workflow extension's three modes are Plan → Build → Default. Plan
|
|
6726
|
+
* mode is read-only and Build mode is unrestricted by design, but DEFAULT
|
|
6727
|
+
* mode was ALSO unrestricted: `resolveSessionRole()` returns undefined
|
|
6728
|
+
* there, so the tool_call gate has no branch to take and the agent edits
|
|
6729
|
+
* files without asking. This gate is the missing permission knob.
|
|
6730
|
+
*
|
|
6731
|
+
* It is deliberately INDEPENDENT of the role gate and of the commit
|
|
6732
|
+
* policy: those govern *what* a role may do, this governs *whether the
|
|
6733
|
+
* user permits a write at all*.
|
|
6734
|
+
*
|
|
6735
|
+
* Returns a block object when the call must not proceed, undefined when it
|
|
6736
|
+
* may. Never throws — a throwing tool_call handler blocks the tool as a
|
|
6737
|
+
* fail-safe, so every failure path here fails CLOSED with an actionable
|
|
6738
|
+
* reason (the reason is what the model reads).
|
|
6739
|
+
*/
|
|
6740
|
+
async function writePolicyGate(
|
|
6741
|
+
event: any,
|
|
6742
|
+
ctx: any,
|
|
6743
|
+
cwd: string,
|
|
6744
|
+
): Promise<{ block: true; reason: string } | undefined> {
|
|
6745
|
+
try {
|
|
6746
|
+
// Scope: the top-level Default-mode session only.
|
|
6747
|
+
// - Plan mode has its own stricter rules (writes only to .pi/plans/).
|
|
6748
|
+
// - Build mode is the deliberate "go ahead" execution escape hatch.
|
|
6749
|
+
// - A spawned child carries PI_WORKFLOW_ROLE and is already read-only
|
|
6750
|
+
// through the role gate (and has no UI to answer a prompt).
|
|
6751
|
+
if (workflowMode !== null) return undefined;
|
|
6752
|
+
if (
|
|
6753
|
+
resolveSessionRole({
|
|
6754
|
+
envRole: process.env[WORKFLOW_ROLE_ENV],
|
|
6755
|
+
workflowMode,
|
|
6756
|
+
}) !== undefined
|
|
6757
|
+
) {
|
|
6758
|
+
return undefined;
|
|
6759
|
+
}
|
|
6760
|
+
|
|
6761
|
+
const resolved = readWritePolicyConfig(cwd, getAgentDir());
|
|
6762
|
+
const config = resolved.config;
|
|
6763
|
+
if (config.mode === "allow") return undefined;
|
|
6764
|
+
|
|
6765
|
+
const classification = classifyWriteCall(
|
|
6766
|
+
event.toolName,
|
|
6767
|
+
event.input,
|
|
6768
|
+
config,
|
|
6769
|
+
);
|
|
6770
|
+
if (!classification.mutating) return undefined;
|
|
6771
|
+
|
|
6772
|
+
// The workflow's own plan directory stays writable in every mode — a
|
|
6773
|
+
// session that leaves Plan mode mid-flow must not be stranded.
|
|
6774
|
+
if (
|
|
6775
|
+
classification.paths.length > 0 &&
|
|
6776
|
+
classification.paths.every((p) => isPlanWritePath(p, cwd))
|
|
6777
|
+
) {
|
|
6778
|
+
return undefined;
|
|
6779
|
+
}
|
|
6780
|
+
|
|
6781
|
+
const allowed = isWriteCallAllowed(classification, config, cwd);
|
|
6782
|
+
const decision = decideWriteGate({
|
|
6783
|
+
mode: config.mode,
|
|
6784
|
+
mutating: true,
|
|
6785
|
+
allowed,
|
|
6786
|
+
});
|
|
6787
|
+
if (decision === "allow") return undefined;
|
|
6788
|
+
|
|
6789
|
+
const target =
|
|
6790
|
+
classification.paths.length > 0
|
|
6791
|
+
? classification.paths.join(", ")
|
|
6792
|
+
: (classification.key ?? String(event.toolName));
|
|
6793
|
+
const why = classification.reason
|
|
6794
|
+
? ` (${classification.reason})`
|
|
6795
|
+
: "";
|
|
6796
|
+
|
|
6797
|
+
if (decision === "block") {
|
|
6798
|
+
return {
|
|
6799
|
+
block: true,
|
|
6800
|
+
reason:
|
|
6801
|
+
`Write policy is 'never' (workflow.writePolicy): the agent may not modify files in Default mode${why}. ` +
|
|
6802
|
+
"Do not retry. Ask the user how to proceed, or tell them to switch to Build mode (/build) — that is the approved-execution path. " +
|
|
6803
|
+
`The user can also change the policy with /write-policy (ask to be prompted, allow to write freely). Target: ${target}`,
|
|
6804
|
+
};
|
|
6805
|
+
}
|
|
6806
|
+
|
|
6807
|
+
// decision === "ask" — prompt the user.
|
|
6808
|
+
const ui = ctx?.ui;
|
|
6809
|
+
if (!ui || !ctx.hasUI) {
|
|
6810
|
+
return {
|
|
6811
|
+
block: true,
|
|
6812
|
+
reason:
|
|
6813
|
+
`Write policy is 'ask' (workflow.writePolicy): writing needs user approval${why}, but no interactive UI is available. ` +
|
|
6814
|
+
"Do not retry. Report what you would change and let the user apply it, or tell them to switch to Build mode (/build). " +
|
|
6815
|
+
`Target: ${target}`,
|
|
6816
|
+
};
|
|
6817
|
+
}
|
|
6818
|
+
|
|
6819
|
+
const isPathGrant = classification.paths.length > 0;
|
|
6820
|
+
const options = writeGateOptions(isPathGrant ? "path" : "command");
|
|
6821
|
+
let sel: unknown;
|
|
6822
|
+
try {
|
|
6823
|
+
sel = await ui.select(
|
|
6824
|
+
`Write policy 'ask': allow this write${why}?`,
|
|
6825
|
+
options,
|
|
6826
|
+
);
|
|
6827
|
+
} catch (_e) {
|
|
6828
|
+
void _e;
|
|
6829
|
+
sel = undefined;
|
|
6830
|
+
}
|
|
6831
|
+
const choice = parseWriteGateChoice(sel);
|
|
6832
|
+
if (choice === "allow-once") return undefined;
|
|
6833
|
+
|
|
6834
|
+
if (choice === "always") {
|
|
6835
|
+
// Persist the grant so the same target stops asking. A path grant
|
|
6836
|
+
// stores the absolute path; a command grant stores the command key.
|
|
6837
|
+
const patch = isPathGrant
|
|
6838
|
+
? {
|
|
6839
|
+
allowedPaths: classification.paths.map((p) =>
|
|
6840
|
+
normalizeWritePath(p, cwd),
|
|
6841
|
+
),
|
|
6842
|
+
}
|
|
6843
|
+
: { allowedCommands: classification.key ? [classification.key] : [] };
|
|
6844
|
+
const written = writeWritePolicyConfig("global", cwd, getAgentDir(), patch);
|
|
6845
|
+
if (written.ok) {
|
|
6846
|
+
try {
|
|
6847
|
+
(ctx as any).ui?.notify?.(
|
|
6848
|
+
`Write policy: always allowed ${isPathGrant ? classification.paths.join(", ") : classification.key}`,
|
|
6849
|
+
"info",
|
|
6850
|
+
);
|
|
6851
|
+
} catch (_e) {
|
|
6852
|
+
void _e;
|
|
6853
|
+
}
|
|
6854
|
+
return undefined;
|
|
6855
|
+
}
|
|
6856
|
+
// The grant could not be persisted — do not silently allow a write
|
|
6857
|
+
// the user asked to remember. Fall through to the denial below.
|
|
6858
|
+
return {
|
|
6859
|
+
block: true,
|
|
6860
|
+
reason:
|
|
6861
|
+
`Write policy 'ask': could not save the permission grant (${written.error ?? "unknown error"}), so the write was not allowed. ` +
|
|
6862
|
+
`Target: ${target}`,
|
|
6863
|
+
};
|
|
6864
|
+
}
|
|
6865
|
+
|
|
6866
|
+
// ── Denial ──
|
|
6867
|
+
// "Deny with instructions…" lets the user say WHY in one sentence
|
|
6868
|
+
// ("only explore and propose a plan, do not edit files yet"), so the
|
|
6869
|
+
// model can change course instead of stopping cold. The sentence
|
|
6870
|
+
// travels through TWO channels:
|
|
6871
|
+
// 1. the block reason (the tool result the model reads right now), and
|
|
6872
|
+
// 2. a USER message (a steer turn the model must obey).
|
|
6873
|
+
// Anthropic shipped the comment-only version and then shipped the bug
|
|
6874
|
+
// where the model ignored it (anthropics/claude-code#89231, #52824):
|
|
6875
|
+
// a tool result is a fact the model may reinterpret, a user turn is an
|
|
6876
|
+
// instruction it must follow.
|
|
6877
|
+
let instruction = "";
|
|
6878
|
+
if (choice === "deny-with-instructions") {
|
|
6879
|
+
const inputFn = (ui as any).input;
|
|
6880
|
+
if (typeof inputFn === "function") {
|
|
6881
|
+
let typed: unknown;
|
|
6882
|
+
try {
|
|
6883
|
+
typed = await inputFn.call(
|
|
6884
|
+
ui,
|
|
6885
|
+
WRITE_INSTRUCTION_PROMPT_TITLE,
|
|
6886
|
+
WRITE_INSTRUCTION_PROMPT_PLACEHOLDER,
|
|
6887
|
+
);
|
|
6888
|
+
} catch (_e) {
|
|
6889
|
+
void _e;
|
|
6890
|
+
typed = undefined;
|
|
6891
|
+
}
|
|
6892
|
+
instruction = sanitizeWriteInstruction(typed);
|
|
6893
|
+
}
|
|
6894
|
+
// No input dialog, a cancelled dialog, or empty text → plain denial
|
|
6895
|
+
// below. A cancel is never an implicit allow.
|
|
6896
|
+
}
|
|
6897
|
+
|
|
6898
|
+
const reason = buildWriteDenyReason({
|
|
6899
|
+
target,
|
|
6900
|
+
why: classification.reason,
|
|
6901
|
+
instruction,
|
|
6902
|
+
});
|
|
6903
|
+
|
|
6904
|
+
if (instruction) {
|
|
6905
|
+
// Channel 2: a user-role message. `steer` queues it while the agent
|
|
6906
|
+
// is running; it lands after the current tool batch, before the next
|
|
6907
|
+
// model call, as a genuine user turn.
|
|
6908
|
+
//
|
|
6909
|
+
// Guard: while a review round is active the extension's `input`
|
|
6910
|
+
// handler routes incoming user text to the REVIEWER, so steering
|
|
6911
|
+
// here would hand the instruction to the wrong agent. The tool result
|
|
6912
|
+
// still carries it, so skip the steer rather than misroute.
|
|
6913
|
+
const rt = reviewRuntime;
|
|
6914
|
+
const wouldRouteToReviewer = shouldRouteInputToReviewer({
|
|
6915
|
+
reviewEnabled: reviewEnabled(),
|
|
6916
|
+
reviewActive: rt?.isRoundActive() === true,
|
|
6917
|
+
hasReviewerSession: rt?.isAlive() === true,
|
|
6918
|
+
});
|
|
6919
|
+
if (!wouldRouteToReviewer) {
|
|
6920
|
+
try {
|
|
6921
|
+
(pi as any).sendUserMessage?.(
|
|
6922
|
+
buildWriteGuidanceMessage({ target, instruction }),
|
|
6923
|
+
{ deliverAs: "steer" },
|
|
6924
|
+
);
|
|
6925
|
+
} catch (_e) {
|
|
6926
|
+
void _e;
|
|
6927
|
+
}
|
|
6928
|
+
}
|
|
6929
|
+
// Audit trail — transcript only (appendEntry is never sent to the LLM).
|
|
6930
|
+
try {
|
|
6931
|
+
pi.appendEntry("workflow-write-denial", {
|
|
6932
|
+
tool: String(event.toolName ?? ""),
|
|
6933
|
+
target,
|
|
6934
|
+
instruction,
|
|
6935
|
+
at: Date.now(),
|
|
6936
|
+
});
|
|
6937
|
+
} catch (_e) {
|
|
6938
|
+
void _e;
|
|
6939
|
+
}
|
|
6940
|
+
}
|
|
6941
|
+
|
|
6942
|
+
return {
|
|
6943
|
+
block: true,
|
|
6944
|
+
reason,
|
|
6945
|
+
// NOTE: no `terminate` here. pi consults `terminate` only AFTER the
|
|
6946
|
+
// whole tool batch has run (`executeToolCallsParallel` prepares and
|
|
6947
|
+
// runs every call before `shouldTerminateToolBatch()`), so it cannot
|
|
6948
|
+
// suppress a sibling write in the same batch — it only decides
|
|
6949
|
+
// whether the agent gets ANOTHER LLM turn. With it set, a plain Deny
|
|
6950
|
+
// would end the run with no model acknowledgement at all: a
|
|
6951
|
+
// behaviour change nobody asked for. The guided path gets its next
|
|
6952
|
+
// turn from the queued steer message instead.
|
|
6953
|
+
};
|
|
6954
|
+
} catch (e: unknown) {
|
|
6955
|
+
// Fail closed: a broken policy read must not become an open door.
|
|
6956
|
+
return {
|
|
6957
|
+
block: true,
|
|
6958
|
+
reason:
|
|
6959
|
+
`Write policy (workflow.writePolicy) could not be evaluated (${e instanceof Error ? e.message : String(e)}), so the write was blocked. ` +
|
|
6960
|
+
"Do not retry. Ask the user to switch to Build mode (/build) or to fix the policy with /write-policy.",
|
|
6961
|
+
};
|
|
6962
|
+
}
|
|
6963
|
+
}
|
|
6964
|
+
|
|
6660
6965
|
// Block edits/writes + bash gating
|
|
6661
6966
|
pi.on("tool_call", async (event, ctx) => {
|
|
6662
6967
|
const cwd = (ctx as ExtensionContext).cwd;
|
|
@@ -6790,6 +7095,15 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
6790
7095
|
reason: `${gateRole} role: ${event.toolName} blocked — this role is read-only.`,
|
|
6791
7096
|
} as any;
|
|
6792
7097
|
}
|
|
7098
|
+
// ── Write permission gate (workflow.writePolicy) ── Independent of the
|
|
7099
|
+
// role gate and of the commit policy: it governs whether the USER
|
|
7100
|
+
// permits a write at all. It only applies in Default mode — Plan has its
|
|
7101
|
+
// own stricter rules and Build is the approved-execution escape hatch —
|
|
7102
|
+
// and it runs last so it only sees calls nothing else claimed.
|
|
7103
|
+
if (workflowMode === null) {
|
|
7104
|
+
const writeBlock = await writePolicyGate(event, ctx, cwd);
|
|
7105
|
+
if (writeBlock) return writeBlock as any;
|
|
7106
|
+
}
|
|
6793
7107
|
// In build mode, ensure todos exist before first real edit
|
|
6794
7108
|
if (
|
|
6795
7109
|
(event.toolName === "edit" || event.toolName === "write") &&
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dieulc/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
|
|
7
7
|
"description": "Plan↔Build mode with an independent Review Mode reviewer, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
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",
|
|
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
11
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
12
12
|
},
|
|
13
13
|
"repository": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"utils.ts",
|
|
28
28
|
"checkpoint.ts",
|
|
29
29
|
"commit-policy.ts",
|
|
30
|
+
"edit-policy.ts",
|
|
30
31
|
"roles.ts",
|
|
31
32
|
"profiles.ts",
|
|
32
33
|
"permissions.ts",
|