@mediadatafusion/pi-workflow-suite 0.0.10 → 0.0.12

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +146 -20
  3. package/VERSION +1 -1
  4. package/agents/codebase-research.md +7 -5
  5. package/agents/general-worker.md +9 -7
  6. package/agents/implementation-planning.md +5 -3
  7. package/agents/quality-validation.md +9 -8
  8. package/agents/workflow-orchestrator.md +9 -7
  9. package/config/prompts/execute-approved-plan.md +12 -2
  10. package/config/prompts/mission-final-validation.md +38 -5
  11. package/config/prompts/mission-plan.md +17 -1
  12. package/config/prompts/mission-repair.md +16 -2
  13. package/config/prompts/mission-review-prompt.md +55 -0
  14. package/config/prompts/mission-run.md +18 -5
  15. package/config/prompts/validate-approved-plan.md +57 -3
  16. package/config/prompts/workflow-plan-prompt.md +11 -1
  17. package/config/prompts/workflow-repair.md +18 -2
  18. package/config/prompts/workflow-reviewer-prompt.md +60 -0
  19. package/config/prompts/workflow-summary.md +1 -4
  20. package/config/workflow-settings.example.json +13 -11
  21. package/docs/assets/mediadatafusion-logo.png +0 -0
  22. package/docs/assets/pi-workflow-suite-demo.gif +0 -0
  23. package/docs/assets/pi-workflow-suite-demo.mp4 +0 -0
  24. package/docs/assets/pi-workflow-suite-header.png +0 -0
  25. package/docs/assets/pi-workflow-suite-video-thumb.png +0 -0
  26. package/docs/assets/readme-link-commands.svg +10 -0
  27. package/docs/assets/readme-link-install.svg +10 -0
  28. package/docs/assets/readme-link-quick-start.svg +10 -0
  29. package/docs/assets/readme-link-settings.svg +10 -0
  30. package/docs/assets/screenshots/.gitkeep +1 -0
  31. package/docs/assets/screenshots/00-mission-home.png +0 -0
  32. package/docs/assets/screenshots/01-startup-Logo.png +0 -0
  33. package/docs/assets/screenshots/02-theme-settings.png +0 -0
  34. package/docs/assets/screenshots/03-GlobalSafetySettings.png +0 -0
  35. package/docs/assets/screenshots/04-SharedSubAgentsSettings.png +0 -0
  36. package/docs/assets/screenshots/05-mission-mode.png +0 -0
  37. package/docs/assets/screenshots/06-diagram-mermaid.png +0 -0
  38. package/extensions/subagent/index.ts +41 -18
  39. package/extensions/subagent/repolock-guard.ts +224 -4
  40. package/extensions/subagent/runner.ts +136 -12
  41. package/extensions/workflow-model-router.ts +152 -55
  42. package/extensions/workflow-modes.ts +4784 -1087
  43. package/extensions/workflow-settings-capabilities.ts +10 -0
  44. package/extensions/workflow-state.ts +139 -15
  45. package/extensions/workflow-subagent-policy.ts +13 -1
  46. package/extensions/workflow-summary.ts +8 -19
  47. package/extensions/workflow-tool-guard.ts +420 -39
  48. package/extensions/workflow-validation-classifier.ts +46 -4
  49. package/extensions/workflow-web-tools.ts +361 -1
  50. package/package.json +10 -5
  51. package/scripts/audit-live.sh +1 -1
  52. package/scripts/build-package-export.mjs +8 -13
  53. package/scripts/check-clean-release-tree.sh +3 -2
  54. package/scripts/check-package-media.mjs +78 -0
  55. package/scripts/install-to-live.sh +2 -0
  56. package/scripts/package-media-config.mjs +28 -0
  57. package/scripts/prepare-package-readme.mjs +19 -18
  58. package/scripts/quarantine-live-junk.sh +1 -1
  59. package/scripts/verify-live.sh +9 -1
  60. package/skills/implementation-planning/SKILL.md +1 -1
  61. package/skills/safe-execution/SKILL.md +1 -1
  62. package/skills/validation-review/SKILL.md +1 -1
@@ -1,9 +1,10 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
- import { isAbsolute, resolve } from "node:path";
3
+ import { isAbsolute, resolve, join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
6
  import { loadWorkflowSettings } from "./workflow-model-router.js";
6
- import type { WorkflowState } from "./workflow-state.js";
7
+ import { loadMissionState, type WorkflowState } from "./workflow-state.js";
7
8
 
8
9
  export const PLAN_TOOLS = ["read", "grep", "find", "ls"];
9
10
  export const WORKFLOW_PROGRESS_TOOL = "workflow_progress";
@@ -22,9 +23,10 @@ export const EXECUTION_RESULT_TOOLS = [WORKFLOW_EXECUTION_RESULT_TOOL, MISSION_M
22
23
  export const VALIDATION_RESULT_TOOLS = [WORKFLOW_VALIDATION_RESULT_TOOL];
23
24
  export const REPAIR_RESULT_TOOLS = [WORKFLOW_REPAIR_RESULT_TOOL];
24
25
  export const STANDARD_RESULT_TOOLS = [STANDARD_HANDOFF_RESULT_TOOL];
25
- export const BASE_EXECUTE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash", WORKFLOW_PROGRESS_TOOL, WORKFLOW_DIAGRAM_TOOL];
26
- export const EXECUTE_TOOLS = [...BASE_EXECUTE_TOOLS, ...EXECUTION_RESULT_TOOLS, ...REPAIR_RESULT_TOOLS];
27
- export const VALIDATOR_TOOLS = ["read", "grep", "find", "ls", "bash", WORKFLOW_DIAGRAM_TOOL, ...REVIEW_RESULT_TOOLS, ...VALIDATION_RESULT_TOOLS];
26
+ export const BASE_EXECUTE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash", WORKFLOW_DIAGRAM_TOOL];
27
+ export const EXECUTE_TOOLS = [...BASE_EXECUTE_TOOLS, WORKFLOW_PROGRESS_TOOL, ...EXECUTION_RESULT_TOOLS, ...REPAIR_RESULT_TOOLS];
28
+ export const REVIEW_TOOLS = ["read", "grep", "find", "ls", WORKFLOW_DIAGRAM_TOOL, ...REVIEW_RESULT_TOOLS];
29
+ export const VALIDATOR_TOOLS = ["read", "grep", "find", "ls", "bash", WORKFLOW_DIAGRAM_TOOL, ...VALIDATION_RESULT_TOOLS];
28
30
 
29
31
 
30
32
  const PATH_SCOPED_TOOLS = new Set(["read", "grep", "find", "ls", "edit", "write"]);
@@ -80,17 +82,90 @@ function piRuntimeInstructionPath(candidate: string): boolean {
80
82
  || rel === "themes" || rel.startsWith("themes/");
81
83
  }
82
84
 
85
+ function packageInstructionPath(candidate: string): boolean {
86
+ const root = safeRealpath(join(dirname(fileURLToPath(import.meta.url)), ".."));
87
+ if (!pathInsideRoot(candidate, root)) return false;
88
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
89
+ return rel === "skills" || rel.startsWith("skills/")
90
+ || rel === "agents" || rel.startsWith("agents/")
91
+ || rel === "config/prompts" || rel.startsWith("config/prompts/")
92
+ || rel === "prompts" || rel.startsWith("prompts/")
93
+ || rel === "themes" || rel.startsWith("themes/");
94
+ }
95
+
96
+ function piClipboardImageTempFile(candidate: string): boolean {
97
+ const base = candidate.split(/[\\/]/).pop() ?? "";
98
+ return /^pi-clipboard-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:png|jpg|jpeg|gif|webp|bmp|tiff|heic)$/i.test(base);
99
+ }
100
+
101
+ function piCodingAgentPackageRoot(): string | undefined {
102
+ try {
103
+ const resolver = (import.meta as ImportMeta & { resolve?: (specifier: string) => string }).resolve;
104
+ if (!resolver) return undefined;
105
+ const entry = fileURLToPath(resolver("@earendil-works/pi-coding-agent"));
106
+ return safeRealpath(resolve(dirname(entry), ".."));
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ function piCodingAgentDocsPath(candidate: string): boolean {
113
+ const root = piCodingAgentPackageRoot();
114
+ if (!root || !pathInsideRoot(candidate, root)) return false;
115
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
116
+ return rel === "docs" || rel.startsWith("docs/");
117
+ }
118
+
119
+ function userInstalledSkillPath(candidate: string): boolean {
120
+ const home = process.env.HOME;
121
+ if (!home) return false;
122
+ const root = safeRealpath(join(home, ".agents", "skills"));
123
+ if (!pathInsideRoot(candidate, root)) return false;
124
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
125
+ const skillName = rel.split(/[\\/]/)[0];
126
+ return Boolean(skillName && skillName !== "." && skillName !== ".." && !skillName.startsWith("."));
127
+ }
128
+
129
+ function workflowStateReadPath(candidate: string): boolean {
130
+ const root = safeRealpath(getAgentDir());
131
+ if (!pathInsideRoot(candidate, root)) return false;
132
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
133
+ return rel === "workflows/active.json"
134
+ || rel === "workflows/plans/latest.json"
135
+ || rel === "workflows/missions/latest.json";
136
+ }
137
+
83
138
  function repoLockPathBlock(pathValue: unknown, cwd: string, tool: string): string | undefined {
84
139
  const root = repoLockRoot(cwd);
85
140
  const candidate = resolveCandidatePath(typeof pathValue === "string" && pathValue.trim() ? pathValue.trim() : ".", cwd);
86
141
  if (!pathInsideRoot(candidate, root)) {
87
- if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && piRuntimeInstructionPath(candidate)) return undefined;
88
- return `Repo Lock blocked path outside current repository: ${candidate} (repo root: ${root})`;
142
+ if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && (piRuntimeInstructionPath(candidate) || packageInstructionPath(candidate) || piCodingAgentDocsPath(candidate) || userInstalledSkillPath(candidate) || workflowStateReadPath(candidate) || piClipboardImageTempFile(candidate))) return undefined;
143
+ if (candidate.startsWith("/private/tmp/") || candidate.startsWith("/tmp/") || candidate.startsWith("/var/tmp/")) return undefined;
144
+ return "Path outside repository";
89
145
  }
90
- if ((tool === "edit" || tool === "write") && protectedRepoPath(candidate, root)) return `Repo Lock blocked ${tool} for protected project control path: ${candidate}`;
146
+ if ((tool === "edit" || tool === "write") && protectedRepoPath(candidate, root)) return "Protected path use settings to disable Repo Lock";
91
147
  return undefined;
92
148
  }
93
149
 
150
+ function stripQuotedSlashes(command: string): string {
151
+ return command
152
+ .replace(/'([^']*)'/g, (_full, content: string) => {
153
+ // If content starts with /regex/ or /regex/flags (awk/sed address pattern), mask slashes
154
+ if (/^\/[^/]+\/(?:[gimp]*$|\s)/.test(content)) return "'" + content.replace(/\//g, " ") + "'";
155
+ // If content starts with /, it's a quoted absolute path — preserve
156
+ if (content.startsWith('/')) return _full;
157
+ // Content contains / but is not a path — mask (sed expression, prose, etc.)
158
+ if (content.includes('/')) return "'" + content.replace(/\//g, " ") + "'";
159
+ return _full;
160
+ })
161
+ .replace(/"([^"]*)"/g, (_full, content: string) => {
162
+ if (/^\/[^/]+\/(?:[gimp]*$|\s)/.test(content)) return '"' + content.replace(/\//g, " ") + '"';
163
+ if (content.startsWith('/')) return _full;
164
+ if (content.includes('/')) return '"' + content.replace(/\//g, " ") + '"';
165
+ return _full;
166
+ });
167
+ }
168
+
94
169
  function stripHereDocBodies(command: string): string {
95
170
  const lines = command.split("\n");
96
171
  const kept: string[] = [];
@@ -111,20 +186,103 @@ function stripUriTokens(command: string): string {
111
186
  }
112
187
 
113
188
  function bashPathCandidates(command: string): string[] {
114
- const trimmed = stripUriTokens(stripHereDocBodies(command)).trim();
189
+ const trimmed = stripUriTokens(stripHereDocBodies(stripQuotedSlashes(command))).trim();
115
190
  if (!trimmed) return [];
116
191
  return Array.from(trimmed.matchAll(/(?:^|[\s=:'"`])((?:\.{1,2}|~|\/)[^\s'"`;&|)]*)/g)).map((match) => match[1]).filter(Boolean);
117
192
  }
118
193
 
194
+ function hasShellControlOperator(command: string): boolean {
195
+ let quote: "'" | '"' | undefined;
196
+ for (let i = 0; i < command.length; i += 1) {
197
+ const char = command[i];
198
+ if (char === "\\") { i += 1; continue; }
199
+ if (quote) {
200
+ if (char === quote) quote = undefined;
201
+ continue;
202
+ }
203
+ if (char === "'" || char === '"') { quote = char; continue; }
204
+ if (char === ";" || char === "|" || char === "&" || char === "<" || char === ">" || char === "\n") return true;
205
+ }
206
+ return quote !== undefined;
207
+ }
208
+
209
+ function shellWords(command: string): string[] | undefined {
210
+ const words: string[] = [];
211
+ let current = "";
212
+ let quote: "'" | '"' | undefined;
213
+ for (let i = 0; i < command.length; i += 1) {
214
+ const char = command[i];
215
+ if (char === "\\") {
216
+ i += 1;
217
+ current += command[i] ?? "";
218
+ continue;
219
+ }
220
+ if (quote) {
221
+ if (char === quote) quote = undefined;
222
+ else current += char;
223
+ continue;
224
+ }
225
+ if (char === "'" || char === '"') { quote = char; continue; }
226
+ if (/\s/.test(char)) {
227
+ if (current) { words.push(current); current = ""; }
228
+ continue;
229
+ }
230
+ current += char;
231
+ }
232
+ if (quote) return undefined;
233
+ if (current) words.push(current);
234
+ return words;
235
+ }
236
+
237
+ function simpleCpSourceOperands(command: string): Set<string> | undefined {
238
+ if (hasShellControlOperator(command)) return undefined;
239
+ const words = shellWords(command);
240
+ if (!words || words.length < 3) return undefined;
241
+ const commandName = words[0].split(/[\\/]/).pop();
242
+ if (commandName !== "cp") return undefined;
243
+ const operands: string[] = [];
244
+ let endOfOptions = false;
245
+ for (const word of words.slice(1)) {
246
+ if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
247
+ if (!endOfOptions && word.startsWith("-")) continue;
248
+ operands.push(word);
249
+ }
250
+ if (operands.length < 2) return undefined;
251
+ return new Set(operands.slice(0, -1));
252
+ }
253
+
254
+ function simpleReadOnlyBashAllowed(command: string): boolean {
255
+ if (hasShellControlOperator(command)) return false;
256
+ const words = shellWords(command);
257
+ if (!words?.length) return false;
258
+ const commandName = words[0].split(/[\\/]/).pop();
259
+ if (commandName === "cat" || commandName === "ls" || commandName === "grep" || commandName === "rg") return true;
260
+ if (commandName !== "find") return false;
261
+ return !words.some((word) => word === "-delete" || word === "-exec" || word === "-execdir" || word === "-ok" || word === "-okdir" || word === "-fprint" || word === "-fprintf");
262
+ }
263
+
264
+ function piCodingAgentDocsBashReadAllowed(command: string): boolean {
265
+ return simpleReadOnlyBashAllowed(command);
266
+ }
267
+
119
268
  function repoLockBashBlock(command: string, cwd: string): string | undefined {
120
269
  const root = repoLockRoot(cwd);
121
270
  const pathCandidates = bashPathCandidates(command);
271
+ const cpSourceOperands = simpleCpSourceOperands(command);
122
272
  for (const raw of pathCandidates) {
123
273
  if (raw === "." || raw === "./" || raw === "/") continue;
124
274
  const cleaned = raw.replace(/[),]+$/, "");
125
275
  if (!cleaned || cleaned.startsWith("./node_modules/.bin")) continue;
276
+ if (cleaned.startsWith("/dev/")) continue;
277
+ if (cleaned.startsWith("/tmp/") || cleaned.startsWith("/private/tmp/") || cleaned.startsWith("/var/tmp/")) continue;
126
278
  const candidate = resolveCandidatePath(cleaned, cwd);
127
- if (!pathInsideRoot(candidate, root)) return `Repo Lock blocked bash path outside current repository: ${cleaned} -> ${candidate} (repo root: ${root})`;
279
+ if (!pathInsideRoot(candidate, root)) {
280
+ if (piCodingAgentDocsPath(candidate) && piCodingAgentDocsBashReadAllowed(command)) continue;
281
+ if (userInstalledSkillPath(candidate) && simpleReadOnlyBashAllowed(command)) continue;
282
+ if (workflowStateReadPath(candidate) && simpleReadOnlyBashAllowed(command)) continue;
283
+ if (piClipboardImageTempFile(candidate) && cpSourceOperands?.has(cleaned)) continue;
284
+ return "Path outside repository";
285
+ }
128
286
  }
129
287
  return undefined;
130
288
  }
@@ -147,6 +305,26 @@ const BLOCKED_EXECUTE_BASH: RegExp[] = [
147
305
  /\bpnpm\s+add\b/i,
148
306
  /\byarn\s+add\b/i,
149
307
  /\bpip\s+install\b/i,
308
+ /\bpip3?\s+install\b/i,
309
+ /\bbundle\s+install\b/i,
310
+ /\bgem\s+install\b/i,
311
+ /\bcargo\s+install\b/i,
312
+ /\bgo\s+(?:get|install)\b/i,
313
+ /\bdeno\s+(?:install|add|cache)\b/i,
314
+ /\bcomposer\s+(?:install|require|update)\b/i,
315
+ /\bmix\s+(?:deps\.get|deps\.compile)\b/i,
316
+ /\bbrew\s+install\b/i,
317
+ /\bapt\s+(?:install|get\s+install)\b/i,
318
+ /\byum\s+install\b/i,
319
+ /\bdnf\s+install\b/i,
320
+ /\bapk\s+add\b/i,
321
+ /\bnuget\s+install\b/i,
322
+ /\bdotnet\s+(?:add\s+package|tool\s+install|restore)\b/i,
323
+ /\bcabal\s+(?:install|update)\b/i,
324
+ /\bstack\s+(?:install|update)\b/i,
325
+ /\bconan\s+install\b/i,
326
+ /\bvcpkg\s+install\b/i,
327
+ /\bcoursier\s+(?:install|fetch)\b/i,
150
328
  /\bcurl\b[^\n]*\|\s*sh\b/i,
151
329
  /\bwget\b[^\n]*\|\s*sh\b/i,
152
330
  /\bvercel\s+deploy\b/i,
@@ -164,8 +342,36 @@ function isPlanMode(mode: WorkflowState["mode"]): boolean {
164
342
  return mode === "awaiting_plan_input" || mode === "awaiting_clarification" || mode === "planning" || mode === "plan_draft" || mode === "plan_approved";
165
343
  }
166
344
 
345
+ function isReviewMode(mode: WorkflowState["mode"]): boolean {
346
+ return mode === "reviewing" || mode === "reviewed";
347
+ }
348
+
349
+ function missionReviewActive(state: WorkflowState): boolean {
350
+ if (state.mode !== "mission_plan_ready") return false;
351
+ const mission = state.activeMissionId ? loadMissionState(state.activeMissionId) : loadMissionState();
352
+ return mission?.currentStep === "reviewer" && mission.reviewRepairInProgress !== true && mission.lastReviewRepairStatus !== "running";
353
+ }
354
+
355
+ function missionReviewAlreadyAccepted(state: WorkflowState): boolean {
356
+ if (state.mode !== "mission_plan_ready" || state.lastWorkflowHandoff?.type !== WORKFLOW_REVIEW_RESULT_TOOL) return false;
357
+ const mission = state.activeMissionId ? loadMissionState(state.activeMissionId) : loadMissionState();
358
+ return mission?.currentStep !== "reviewer" && (mission?.reviewerVerdict === "PASS" || mission?.reviewerVerdict === "NOTES");
359
+ }
360
+
361
+ function initialPlanHandoffAlreadyAccepted(state: WorkflowState): boolean {
362
+ return state.mode === "plan_draft"
363
+ && state.reviewHandoffSuppression?.kind === "plan_typed_initial_to_approval"
364
+ && state.lastWorkflowHandoff?.type === WORKFLOW_PLAN_RESULT_TOOL;
365
+ }
366
+
367
+ function reviewPhaseSubagentCall(input: unknown): boolean {
368
+ if (!input || typeof input !== "object") return false;
369
+ const workflowPhase = String((input as { workflowPhase?: unknown }).workflowPhase ?? "").toLowerCase();
370
+ return workflowPhase === "review";
371
+ }
372
+
167
373
  function isValidatorMode(mode: WorkflowState["mode"]): boolean {
168
- return mode === "reviewing" || mode === "reviewed" || mode === "validating" || mode === "revalidating" || mode === "validated" || mode === "mission_validating" || mode === "mission_revalidating" || mode === "mission_final_validating";
374
+ return mode === "validating" || mode === "revalidating" || mode === "mission_validating" || mode === "mission_revalidating" || mode === "mission_final_validating";
169
375
  }
170
376
 
171
377
  function isValidationResultMode(mode: WorkflowState["mode"]): boolean {
@@ -180,9 +386,17 @@ function isSubagentWorker(): boolean {
180
386
  return process.env.PI_SUBAGENT_WORKER === "1";
181
387
  }
182
388
 
389
+ const PACKAGE_INSTALL_RE = /\b(?:npm\s+install|pnpm\s+add|yarn\s+add|pip3?\s+install|bundle\s+install|gem\s+install|cargo\s+install|go\s+(?:get|install)|deno\s+(?:install|add|cache)|composer\s+(?:install|require|update)|mix\s+deps\.(?:get|compile)|brew\s+install|apt(?:-get)?\s+install|yum\s+install|dnf\s+install|apk\s+add|nuget\s+install|dotnet\s+(?:add\s+package|tool\s+install|restore)|cabal\s+(?:install|update)|stack\s+(?:install|update)|conan\s+install|vcpkg\s+install|coursier\s+(?:install|fetch))\b/i;
390
+
391
+ function isPackageInstallCommand(command: string): boolean {
392
+ return PACKAGE_INSTALL_RE.test(command);
393
+ }
394
+
183
395
  function commandBlocked(command: string, cwd?: string): boolean {
184
396
  const settings = loadWorkflowSettings(cwd);
185
- return settings.safety.blockDestructiveCommands !== false && isBlockedExecuteCommand(command);
397
+ if (settings.safety.blockDestructiveCommands === false) return false;
398
+ if (isPackageInstallCommand(command) && settings.safety.allowPackageInstallInExecution !== false) return false;
399
+ return isBlockedExecuteCommand(command);
186
400
  }
187
401
 
188
402
  function standardTodoMode(settings: ReturnType<typeof loadWorkflowSettings>): "off" | "manual" | "auto" | "required" {
@@ -202,17 +416,77 @@ function standardTaskLooksSubstantive(task: string | undefined): boolean {
202
416
  return text.length >= 8 || text.split(/\s+/).filter(Boolean).length >= 2;
203
417
  }
204
418
 
205
- function standardSafeReadOnlyBash(command: string): boolean {
419
+ function stripTimeoutPrefix(command: string): string {
420
+ return command.replace(/^timeout\s+\d+[smhd]?\s+/, "").trim() || command;
421
+ }
422
+
423
+ const DESTRUCTIVE_WORD_RE = /\b(?:install|add|update|upgrade|publish|deploy|push|checkout|switch|commit|merge|rebase|stash|tag|apply|am|restore|sed\s+-i|perl\s+-pi|chmod|chown|curl\s.*\|\s*(?:sh|bash)|wget\s.*\|\s*(?:sh|bash))\b/i;
424
+
425
+ const SAFE_READ_ONLY_COMMANDS_RE = /^(?:git\s+(?:status|log|diff|show|branch|rev-parse|ls-files|describe|remote|tag|shortlog|count-objects|blame|name-rev)\b|cat\b|head\b|tail\b|less\b|more\b|wc\b|file\b|stat\b|which\b|where\b|command\s+-v\b|type\b|echo\b|printf\b|printenv\b|env\b|uname\b|date\b|id\b|whoami\b|hostname\b|pwd\b|ls\b|du\b|df\b|diff\b|comm\b|sort\b|uniq\b|cut\b|tr\b|awk\b|jq\b|yq\b|xq\b|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|test|lint|typecheck|type-check|check[\s:]?\w*|dev|start|preview|serve|watch|format|analyze|compile|ci|validate|verify|coverage|bench|benchmark|bundle|pack|dist|static|docs|doc|stylelint|e2e|integration|unit)\b|(?:npm|pnpm|yarn|bun)\s+(?:exec|info|ls|list|query|outdated|why|view|pack\s+--dry-run)\b|npx\s+(?:serve|http-server|lite-server|tsc|vite|eslint|prettier|vitest|jest|mocha|cypress|playwright|webpack|rollup|parcel|turbo|nx|ts-node|tsx|esbuild|swc|babel|stylelint|biome|rome|knip|typedoc|compodoc|angular-cli|react-scripts|next|nuxt|remix|astro|svelte-kit)\b|pnpm\s+(?:exec|dlx)\s+\w+\b|bun\s+(?:test|check|build|run)\b|deno\s+(?:check|test|build|lint|task|info|doc|compile|fmt|eval|cache)\b|cargo\s+(?:build|test|check|clippy|doc|bench|run|metadata|locate-project|tree|version)\b|(?:rustc|rustup)\s+(?:--version|--print|which)\b|go\s+(?:build|test|vet|run|doc|list|mod\s+(?:verify|tidy|graph|download|why))\b|python3?\s+(?:--version|-V|-c\b|-m\s+(?:pytest|unittest|mypy|pylint|flake8|black|isort|ruff|json\.tool|compileall|bandit|pyright|http\.server|html\.parser|html))\b|pip3?\s+(?:list|show|check|debug|index\s+versions)\b|tsc\b|node\s+(?:--version|-v|--check|-c|-e|--eval)\b|make\s+(?:build|test|check|lint|all|verify|docs|format|static|analyze)\b|cmake\s+(?:--build|--version)\b|(?:dotnet|msbuild)\s+(?:build|test|restore|check|format|lint|pack)\b|(?:gradle|\.\/gradlew|gradlew\.bat)\s+(?:build|test|check|compile|lint|dependencies|projects|tasks)\b|mvn\s+(?:compile|test|verify|checkstyle|pmd|versions:display|dependency:tree|dependency:list)\b|(?:swift|swiftc)\s+(?:build|test|package\s+(?:describe|dump-package))\b|(?:bundle|gem)\s+(?:exec|list|check|info|query)\b|rake\s+(?:test|spec|lint|check|notes|stats|about)\b|php\s+(?:--version|-v|-l)\b|(?:php\s+)?artisan\s+(?:--version|route:list|config:show|env)\b|composer\s+(?:validate|check|show|outdated|info|diagnose)\b|mix\s+(?:test|compile|lint|format|docs)\b|bazel\s+(?:build|test|query|cquery|info|version)\b|buck\s+(?:build|test|query|audit)\b|curl\s+(?:-[^\s]*[sSfIv][^\s]*\s+)+(?:https?:\/\/|localhost|\$)|kill\s+\$!\b|kill\s+-0\s+\$\w+\b|wait\s+\$!\b|wait\s+\$\w+\b|sleep\s+[0-9.]+[smhd]?\b|ps\s+(?:aux?|-[a-z]*[eE][a-z]*|-[a-z]*[pP][a-z]*)\b|pgrep\s+-\w+\s+\w+|true\b|false\b|\.\s*\/node_modules\/\.bin\/\S+\b)/i;
426
+
427
+ export function standardSafeReadOnlyBash(command: string): boolean {
206
428
  const trimmed = command.trim();
207
429
  if (!trimmed || isBlockedExecuteCommand(trimmed)) return false;
208
- return /^(?:git\s+(?:status|log|diff|show|branch|rev-parse)\b|python3?\s+-m\s+json\.tool\b|npm\s+run\s+(?:lint|test)\b|npx\s+tsc\s+--noEmit\b|tsc\s+--noEmit\b)/i.test(trimmed);
430
+ const cmd = stripTimeoutPrefix(trimmed);
431
+ return SAFE_READ_ONLY_COMMANDS_RE.test(cmd);
432
+ }
433
+
434
+ function stripSafePreamble(command: string): string {
435
+ return command.replace(/^(?:set\s+[-+][euxo]+(?:\s+[^\n]*)?|export\s+\w+=["']?[^\n"']*["']?|\w+=\S+)\s*\n+/gm, "").trim() || command;
436
+ }
437
+
438
+ function stripBenignValidationRedirections(command: string): string {
439
+ return command
440
+ .replace(/\s+\d?>&\d\b/g, "")
441
+ .replace(/\s+\d?>\s*(?:\/tmp|\/private\/tmp|\/dev\/null)\/?\S*/g, "")
442
+ .replace(/\s+\d?>>\s*(?:\/tmp|\/private\/tmp|\/dev\/null)\/?\S*/g, "")
443
+ .replace(/\s+&\s*$/g, "")
444
+ .trim();
445
+ }
446
+
447
+ function validationWriteVectorBlocked(command: string): boolean {
448
+ if (/<<[-~]?\s*['"]?\w+/i.test(command)) return true;
449
+ if (/(?:\btee\b|\bsed\s+-i\b|\bperl\s+-pi\b|\bcat\s*>)/i.test(command)) return true;
450
+ if (/(?:^|\s)(?:>|>>)\s*(?!\/tmp\/|\/private\/tmp\/|\/dev\/null\b|&\d\b)\S+/i.test(command)) return true;
451
+ if (/\b(?:python3?|node|perl|ruby|bash|sh)\s+(?:\/tmp|\/private\/tmp)\/\S+/i.test(command)) return true;
452
+ if (/\bpython3?\s+-c\b[\s\S]*(?:open\s*\([^)]*,\s*['"][wa+]|write\s*\()/i.test(command)) return true;
453
+ if (/\bnode\s+(?:-e|--eval)\b[\s\S]*(?:writeFile|appendFile|rmSync|mkdirSync|renameSync|copyFileSync)/i.test(command)) return true;
454
+ if (/\bperl\s+-e\b[\s\S]*(?:open\s*\([^)]*[>]|print\s+\w+\s+)/i.test(command)) return true;
455
+ if (/\bruby\s+-e\b[\s\S]*(?:File\.(?:write|open|delete|rename)|IO\.write)/i.test(command)) return true;
456
+ return false;
457
+ }
458
+
459
+ function validatorSafeReadOnlySegment(segment: string): boolean {
460
+ const cleaned = stripBenignValidationRedirections(stripTimeoutPrefix(segment.trim()));
461
+ return Boolean(cleaned) && standardSafeReadOnlyBash(cleaned);
462
+ }
463
+
464
+ function validatorSafeReadOnlyPipeline(command: string): boolean {
465
+ const parts = command.split("|").map((part) => part.trim()).filter(Boolean);
466
+ return parts.length > 1 && parts.every(validatorSafeReadOnlySegment);
467
+ }
468
+
469
+ function validatorSafeDevServerComposite(command: string): boolean {
470
+ const lines = command.split(/\n+/).map((line) => line.trim()).filter(Boolean);
471
+ if (lines.length < 2) return false;
472
+ const first = lines[0];
473
+ const serverStart = /^(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|preview|serve)\b|npx\s+(?:vite|next|serve|http-server|lite-server)\b|python3?\s+-m\s+http\.server\b)/i.test(first);
474
+ const safeOutput = /\s(?:>|>>)\s*(?:\/tmp\/|\/private\/tmp\/|\/dev\/null\b)/.test(first) || /(?:^|\s)&\s*$/.test(first);
475
+ if (!serverStart || !safeOutput || !/(?:^|\s)&\s*$/.test(first)) return false;
476
+ return lines.slice(1).every((line) => line.split("&&").map((part) => part.trim()).filter(Boolean).every(validatorSafeReadOnlySegment));
209
477
  }
210
478
 
211
479
  function validatorSafeEvidenceBash(command: string): boolean {
212
480
  const trimmed = command.trim();
213
- if (!trimmed || isBlockedExecuteCommand(trimmed)) return false;
214
- if (/\b(?:install|add|update|upgrade|publish|deploy|push|reset|clean|checkout|switch|commit|merge|rebase|stash|tag|apply|am|restore|rm|mv|cp|mkdir|touch|sed\s+-i|perl\s+-pi|tee|chmod|chown|kill|open)\b/i.test(trimmed)) return false;
215
- return /^(?:git\s+(?:status|log|diff|show|branch|rev-parse|ls-files)\b|npm\s+run\s+(?:typecheck|check:ts|lint|test|build)\b|npx\s+tsc\s+--noEmit\b|tsc\s+--noEmit\b|python3?\s+-m\s+json\.tool\b)/i.test(trimmed);
481
+ if (!trimmed) return false;
482
+ const cmd = stripSafePreamble(stripTimeoutPrefix(trimmed));
483
+ if (isBlockedExecuteCommand(cmd)) return false;
484
+ if (DESTRUCTIVE_WORD_RE.test(cmd)) return false;
485
+ if (validationWriteVectorBlocked(cmd)) return false;
486
+ if (standardSafeReadOnlyBash(stripBenignValidationRedirections(cmd))) return true;
487
+ if (validatorSafeReadOnlyPipeline(cmd)) return true;
488
+ if (validatorSafeDevServerComposite(cmd)) return true;
489
+ return false;
216
490
  }
217
491
 
218
492
  function standardTodoTitleLooksGeneric(title: string): boolean {
@@ -238,6 +512,43 @@ function standardRequiredTodoMissing(state: WorkflowState, settings: ReturnType<
238
512
  && standardTaskLooksSubstantive(task);
239
513
  }
240
514
 
515
+ function planProgressRelevantWorkTool(tool: string, input: unknown): boolean {
516
+ if (tool === "read" || tool === "grep" || tool === "find" || tool === "ls" || tool === "edit" || tool === "write" || tool === "subagent") return true;
517
+ if (tool !== "bash") return false;
518
+ const command = String((input as { command?: unknown } | undefined)?.command ?? "");
519
+ return Boolean(command.trim()) && !standardSafeReadOnlyBash(command);
520
+ }
521
+
522
+ function currentPlanProgressStepNumber(state: WorkflowState): number | undefined {
523
+ const steps = state.planProgress?.steps ?? [];
524
+ if (!steps.length) return undefined;
525
+ if (steps.every((step) => step.status === "completed" || step.status === "skipped")) return undefined;
526
+ const activeIndex = steps.findIndex((step) => step.status === "active");
527
+ const fallbackIndex = Math.max(0, Math.min(steps.length - 1, Math.floor(state.planProgress?.currentStepIndex ?? 0)));
528
+ return (activeIndex >= 0 ? activeIndex : fallbackIndex) + 1;
529
+ }
530
+
531
+ function planProgressToolRequiredBlock(state: WorkflowState, tool: string, input: unknown): string | undefined {
532
+ if (!planProgressRelevantWorkTool(tool, input)) return undefined;
533
+ const stalePlanExecutionWait =
534
+ (state.mode === "reviewed" || state.mode === "plan_approved" || state.mode === "validated")
535
+ && Boolean(state.approvedPlan)
536
+ && Boolean(state.planProgress?.steps?.length)
537
+ && state.planProgress?.lifecycleStatus !== "completed";
538
+ if (stalePlanExecutionWait) {
539
+ const stepNumber = currentPlanProgressStepNumber(state);
540
+ if (!stepNumber) return `Plan ${tool} is blocked while the approved Plan is in ${state.mode} with lifecycleStatus=${state.planProgress?.lifecycleStatus ?? "unknown"}. Use the appropriate Plan command to arm a fresh workflow phase before running work tools.`;
541
+ return `Plan execution ${tool} is blocked until the workflow re-enters execution and workflow_progress({ step: ${stepNumber}, status: "active" }) is called for the current approved Plan step. Run /plan continue to arm a fresh executor turn.`;
542
+ }
543
+ const stepNumber = currentPlanProgressStepNumber(state);
544
+ if (!stepNumber) return undefined;
545
+ if (state.mode !== "executing" && state.mode !== "repairing") return undefined;
546
+ // Only the explicit workflow_progress tool call can satisfy this gate.
547
+ // Display/fallback active steps are widget state, not execution proof.
548
+ if (state.planProgressLastToolStatus === "active" && state.planProgressLastToolStep === stepNumber) return undefined;
549
+ return `Plan execution ${tool} is blocked until workflow_progress({ step: ${stepNumber}, status: "active" }) is called for the current approved Plan step.`;
550
+ }
551
+
241
552
  export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowState): void {
242
553
  pi.on("tool_call", async (event, ctx) => {
243
554
  const state = getState();
@@ -263,51 +574,111 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
263
574
  }
264
575
 
265
576
  if (isSubagentWorker()) {
577
+ const workerPhase = String(process.env.PI_WORKFLOW_SUBAGENT_PHASE ?? "").toLowerCase();
578
+ const workerReadOnlyPhase = workerPhase === "validation" || workerPhase === "review";
579
+ if (workerReadOnlyPhase && (tool === "edit" || tool === "write")) {
580
+ return { block: true, reason: `${tool} blocked — ${workerPhase} sub-agent workers are read-only` };
581
+ }
266
582
  if (tool === "bash") {
267
583
  const command = String((event.input as { command?: unknown }).command ?? "");
268
- if (commandBlocked(command, ctx.cwd)) return { block: true, reason: `Workflow safety blocked destructive sub-agent bash command: ${command}` };
584
+ if (workerReadOnlyPhase && !validatorSafeEvidenceBash(command)) return { block: true, reason: `Bash blocked unsafe command in ${workerPhase} sub-agent worker` };
585
+ if (commandBlocked(command, ctx.cwd)) return { block: true, reason: "Destructive command blocked" };
269
586
  }
270
587
  return;
271
588
  }
272
589
 
273
- if (tool === STANDARD_HANDOFF_RESULT_TOOL && state.mode !== "standard") return { block: true, reason: "Standard handoff result is only available while Standard Mode is active." };
590
+ if (tool === STANDARD_HANDOFF_RESULT_TOOL && state.mode !== "standard") return { block: true, reason: "Standard handoff unavailable outside Standard Mode" };
591
+
592
+ if (tool === WORKFLOW_PLAN_RESULT_TOOL && state.mode !== "planning" && state.mode !== "executing" && state.mode !== "repairing") return { block: true, reason: `${tool} unavailable outside planning phase` };
593
+ if (tool === MISSION_PLAN_RESULT_TOOL && state.mode !== "mission_planning") return { block: true, reason: "mission_plan_result is only for mission planning. Use mission_milestone_result to submit milestone execution checkpoints." };
594
+ const staleAcceptedPlanReviewResult = tool === WORKFLOW_REVIEW_RESULT_TOOL
595
+ && state.mode !== "reviewing"
596
+ && !missionReviewActive(state)
597
+ && (
598
+ state.reviewHandoffSuppression?.kind === "plan_typed_review_to_execution"
599
+ || (state.lastWorkflowHandoff?.type === WORKFLOW_REVIEW_RESULT_TOOL && (state.mode === "reviewed" || state.reviewRepairInProgress === true || state.lastReviewRepairStatus === "running"))
600
+ );
601
+ const staleAcceptedMissionReviewResult = tool === WORKFLOW_REVIEW_RESULT_TOOL && missionReviewAlreadyAccepted(state);
602
+ if (tool === WORKFLOW_REVIEW_RESULT_TOOL && !staleAcceptedPlanReviewResult && !staleAcceptedMissionReviewResult && state.mode !== "reviewing" && !missionReviewActive(state)) return { block: true, reason: "Review handoff unavailable outside review phase" };
603
+ if (tool === WORKFLOW_EXECUTION_RESULT_TOOL && state.mode !== "executing") return { block: true, reason: "Execution handoff unavailable outside execution phase" };
604
+ if (tool === MISSION_MILESTONE_RESULT_TOOL && state.mode !== "mission_running") return { block: true, reason: "Milestone handoff unavailable outside Mission execution" };
605
+ if (tool === WORKFLOW_VALIDATION_RESULT_TOOL && !isValidationResultMode(state.mode)) return { block: true, reason: "Validation handoff unavailable outside validation phase" };
606
+ if (tool === WORKFLOW_REPAIR_RESULT_TOOL && state.mode !== "repairing" && state.mode !== "mission_repairing") return { block: true, reason: "Repair handoff unavailable outside repair phase" };
607
+
608
+ if (tool === WORKFLOW_PROGRESS_TOOL && state.mode !== "executing" && state.mode !== "repairing") return { block: true, reason: "Progress tracking unavailable outside Plan execution" };
609
+
610
+ if (initialPlanHandoffAlreadyAccepted(state) && planProgressRelevantWorkTool(tool, event.input)) {
611
+ return { block: true, reason: "Plan already accepted; execution must start from the queued executor or approval path." };
612
+ }
274
613
 
275
- if ((tool === WORKFLOW_PLAN_RESULT_TOOL && state.mode !== "planning") || (tool === MISSION_PLAN_RESULT_TOOL && state.mode !== "mission_planning")) return { block: true, reason: `${tool} is only available during its planning phase.` };
276
- if (tool === WORKFLOW_REVIEW_RESULT_TOOL && state.mode !== "reviewing" && state.mode !== "mission_plan_ready") return { block: true, reason: "workflow_review_result is only available during review phases." };
277
- if (tool === WORKFLOW_EXECUTION_RESULT_TOOL && state.mode !== "executing") return { block: true, reason: "workflow_execution_result is only available during Plan execution." };
278
- if (tool === MISSION_MILESTONE_RESULT_TOOL && state.mode !== "mission_running") return { block: true, reason: "mission_milestone_result is only available during Mission execution." };
279
- if (tool === WORKFLOW_VALIDATION_RESULT_TOOL && !isValidationResultMode(state.mode)) return { block: true, reason: "workflow_validation_result is only available during validation phases." };
280
- if (tool === WORKFLOW_REPAIR_RESULT_TOOL && state.mode !== "repairing" && state.mode !== "mission_repairing") return { block: true, reason: "workflow_repair_result is only available during repair phases." };
614
+ if (tool === "subagent" && state.reviewHandoffSuppression?.kind === "plan_typed_review_to_execution" && reviewPhaseSubagentCall(event.input)) {
615
+ return { block: true, reason: "Plan review already accepted; stale Review-phase sub-agent call ignored. Execution has started." };
616
+ }
617
+ if (tool === "subagent" && state.mode === "reviewed" && state.lastWorkflowHandoff?.type === WORKFLOW_REVIEW_RESULT_TOOL) {
618
+ return { block: true, reason: "Plan review already accepted; stale review sub-agent call ignored. Execution remains queued." };
619
+ }
620
+ if (tool === "subagent" && state.reviewRepairInProgress === true && state.lastWorkflowHandoff?.type === WORKFLOW_REVIEW_RESULT_TOOL) {
621
+ return { block: true, reason: "Plan review handoff is already accepted; do not launch more review sub-agents. Workflow Suite will continue from the accepted review state." };
622
+ }
623
+ if (tool === "subagent" && missionReviewAlreadyAccepted(state)) {
624
+ return { block: true, reason: "Mission review already accepted; stale review sub-agent call ignored. Mission approval remains queued." };
625
+ }
626
+ if (tool === "subagent" && state.mode === "mission_plan_ready" && !missionReviewActive(state)) {
627
+ return { block: true, reason: "Mission review is not active; start the reviewer with /mission review or the Mission review menu before launching review sub-agents." };
628
+ }
281
629
 
282
630
  if (tool === "standard_todo") {
283
- if (state.mode !== "standard") return { block: true, reason: "Standard Mode To Do is only available while Standard Mode is active." };
284
- if (state.standardClarificationPending || state.standardClarificationStage === "drafting" || state.standardClarificationStage === "awaiting_answer") return { block: true, reason: "Standard Mode To Do is blocked until the pending Standard clarification is answered." };
631
+ if (state.mode !== "standard") return { block: true, reason: "To Do unavailable outside Standard Mode" };
285
632
  }
286
633
 
287
634
  if (state.mode === "standard" && tool !== "standard_todo" && standardRequiredTodoMissing(state, settings)) {
288
- if (tool === "edit" || tool === "write" || tool === "subagent") return { block: true, reason: `Standard Mode ${tool} is blocked until required dynamic task-specific To Do tracking is initialized with standard_todo.` };
635
+ if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} blocked To Do required` };
636
+ if (tool === "bash") {
637
+ const command = String((event.input as { command?: unknown }).command ?? "");
638
+ if (!standardSafeReadOnlyBash(command)) return { block: true, reason: "Bash blocked — To Do required" };
639
+ }
640
+ }
641
+
642
+ if (state.mode === "standard" && state.standardClarificationPending) {
643
+ if (tool === "edit" || tool === "write")
644
+ return { block: true, reason: `${tool} blocked — Standard Mode clarification is pending. Answer the clarification questions first.` };
289
645
  if (tool === "bash") {
290
646
  const command = String((event.input as { command?: unknown }).command ?? "");
291
- if (!standardSafeReadOnlyBash(command)) return { block: true, reason: "Standard Mode bash is blocked until required dynamic task-specific To Do tracking is initialized with standard_todo." };
647
+ if (!standardSafeReadOnlyBash(command))
648
+ return { block: true, reason: "Bash blocked — Standard Mode clarification is pending. Answer the clarification questions first." };
292
649
  }
293
650
  }
294
651
 
652
+ const planProgressBlock = planProgressToolRequiredBlock(state, tool, event.input);
653
+ if (planProgressBlock) return { block: true, reason: planProgressBlock };
654
+
295
655
  if (isPlanMode(state.mode)) {
296
- if (tool === "edit" || tool === "write") return { block: true, reason: `Workflow Plan Mode blocks ${tool}. Allowed tools: ${PLAN_TOOLS.join(", ")}${settings.safety.disableBashInPlanMode === false ? ", bash (safe commands)" : ""}` };
297
- if (tool === "bash" && settings.safety.disableBashInPlanMode !== false) return { block: true, reason: `Workflow Plan Mode blocks bash. Allowed tools: ${PLAN_TOOLS.join(", ")}` };
656
+ if (state.mode === "plan_approved" && state.approvedPlan) return;
657
+ if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} not available in Plan mode` };
658
+ if (tool === "bash" && settings.safety.disableBashInPlanMode !== false) return { block: true, reason: "Bash not available in Plan mode" };
659
+ }
660
+
661
+ if (isReviewMode(state.mode)) {
662
+ if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} not available in review mode` };
663
+ if (tool === "bash") return { block: true, reason: "Bash not available in review mode" };
664
+ }
665
+
666
+ if (state.mode === "mission_plan_ready") {
667
+ if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} not available during mission plan review` };
668
+ if (tool === "bash") return { block: true, reason: "Bash not available during mission plan review" };
298
669
  }
299
670
 
300
671
  if (isValidatorMode(state.mode)) {
301
- if (tool === "edit" || tool === "write") return { block: true, reason: `Workflow Review/Validator Mode blocks ${tool}. Allowed tools: ${VALIDATOR_TOOLS.join(", ")}` };
302
- if (tool === "bash") {
672
+ if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} not available in validation mode` };
673
+ if (tool === "bash" && settings.safety.disableBashInValidatorMode !== false) {
303
674
  const command = String((event.input as { command?: unknown }).command ?? "");
304
- if (!validatorSafeEvidenceBash(command)) return { block: true, reason: `Workflow Review/Validator Mode blocks unsafe bash. Allowed bash is limited to safe read-only evidence commands.` };
675
+ if (!validatorSafeEvidenceBash(command)) return { block: true, reason: "Bash blocked unsafe command in validation mode" };
305
676
  }
306
677
  }
307
678
 
308
- if ((isExecutionMode(state.mode) || isPlanMode(state.mode) || isValidatorMode(state.mode)) && tool === "bash") {
679
+ if ((isExecutionMode(state.mode) || isPlanMode(state.mode) || isValidatorMode(state.mode) || isReviewMode(state.mode)) && tool === "bash") {
309
680
  const command = String((event.input as { command?: unknown }).command ?? "");
310
- if (commandBlocked(command, ctx.cwd)) return { block: true, reason: `Workflow safety blocked destructive or out-of-scope bash command: ${command}` };
681
+ if (commandBlocked(command, ctx.cwd)) return { block: true, reason: "Destructive or out-of-scope command blocked" };
311
682
  }
312
683
  });
313
684
 
@@ -316,7 +687,11 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
316
687
  const settings = loadWorkflowSettings(ctx.cwd);
317
688
 
318
689
  if (isSubagentWorker()) {
319
- if (commandBlocked(event.command, ctx.cwd)) return { result: { output: `Workflow safety blocked destructive sub-agent command: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
690
+ const workerPhase = String(process.env.PI_WORKFLOW_SUBAGENT_PHASE ?? "").toLowerCase();
691
+ if ((workerPhase === "validation" || workerPhase === "review") && !validatorSafeEvidenceBash(event.command)) {
692
+ return { result: { output: `Workflow ${workerPhase} sub-agent blocks unsafe user bash: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
693
+ }
694
+ if (commandBlocked(event.command, ctx.cwd)) return { result: { output: "Destructive command blocked", exitCode: 1, cancelled: false, truncated: false } };
320
695
  return;
321
696
  }
322
697
 
@@ -328,11 +703,17 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
328
703
  if (isPlanMode(state.mode) && settings.safety.disableBashInPlanMode !== false) {
329
704
  return { result: { output: `Workflow ${state.mode} blocks user bash: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
330
705
  }
706
+ if (isReviewMode(state.mode)) {
707
+ return { result: { output: `Workflow ${state.mode} blocks user bash during read-only review: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
708
+ }
709
+ if (state.mode === "mission_plan_ready") {
710
+ return { result: { output: `Workflow ${state.mode} blocks user bash during mission plan review: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
711
+ }
331
712
  if (isValidatorMode(state.mode) && !validatorSafeEvidenceBash(event.command)) {
332
713
  return { result: { output: `Workflow ${state.mode} blocks unsafe user bash: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
333
714
  }
334
- if ((isExecutionMode(state.mode) || isPlanMode(state.mode) || isValidatorMode(state.mode)) && commandBlocked(event.command, ctx.cwd)) {
335
- return { result: { output: `Workflow safety blocked destructive command: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
715
+ if ((isExecutionMode(state.mode) || isPlanMode(state.mode) || isValidatorMode(state.mode) || isReviewMode(state.mode)) && commandBlocked(event.command, ctx.cwd)) {
716
+ return { result: { output: "Destructive command blocked", exitCode: 1, cancelled: false, truncated: false } };
336
717
  }
337
718
  });
338
719
  }