@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/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
- getArgumentCompletions: (prefix: string) => {
1591
- const flags = [
1592
- {
1593
- value: "never",
1594
- label: "never",
1595
- description:
1596
- "agent cannot git add/commit/push — you commit (or /commit)",
1597
- },
1598
- {
1599
- value: "ask",
1600
- label: "ask",
1601
- description: "agent may commit; each git commit/push asks first",
1602
- },
1603
- {
1604
- value: "allow",
1605
- label: "allow",
1606
- description:
1607
- "agent may commit freely (per-step during plan execution)",
1608
- },
1609
- {
1610
- value: "global",
1611
- label: "global",
1612
- description: "write to ~/.pi/agent/settings.json",
1613
- },
1614
- {
1615
- value: "project",
1616
- label: "project",
1617
- description: "write to <cwd>/.pi/settings.json",
1618
- },
1619
- ];
1620
- const filtered = flags.filter((f) => f.value.startsWith(prefix));
1621
- return filtered.length ? filtered : null;
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(describeProfile(named), "info");
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
- ensureProfilesConfig();
4566
- const subs = [
4567
- { value: "list", label: "list", description: "show all profiles" },
4568
- {
4569
- value: "use",
4570
- label: "use <name|none>",
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.5.1",
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",