@mediadatafusion/pi-workflow-suite 0.0.11 → 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 +36 -0
  2. package/README.md +26 -17
  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 +19 -6
  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 +25 -9
  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 +124 -41
  42. package/extensions/workflow-modes.ts +3791 -967
  43. package/extensions/workflow-settings-capabilities.ts +10 -0
  44. package/extensions/workflow-state.ts +77 -10
  45. package/extensions/workflow-subagent-policy.ts +13 -1
  46. package/extensions/workflow-summary.ts +8 -19
  47. package/extensions/workflow-tool-guard.ts +326 -35
  48. package/extensions/workflow-validation-classifier.ts +46 -4
  49. package/extensions/workflow-web-tools.ts +361 -1
  50. package/package.json +9 -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
@@ -4,7 +4,7 @@ import { isAbsolute, resolve, join, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { loadWorkflowSettings } from "./workflow-model-router.js";
7
- import type { WorkflowState } from "./workflow-state.js";
7
+ import { loadMissionState, type WorkflowState } from "./workflow-state.js";
8
8
 
9
9
  export const PLAN_TOOLS = ["read", "grep", "find", "ls"];
10
10
  export const WORKFLOW_PROGRESS_TOOL = "workflow_progress";
@@ -25,7 +25,8 @@ export const REPAIR_RESULT_TOOLS = [WORKFLOW_REPAIR_RESULT_TOOL];
25
25
  export const STANDARD_RESULT_TOOLS = [STANDARD_HANDOFF_RESULT_TOOL];
26
26
  export const BASE_EXECUTE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash", WORKFLOW_DIAGRAM_TOOL];
27
27
  export const EXECUTE_TOOLS = [...BASE_EXECUTE_TOOLS, WORKFLOW_PROGRESS_TOOL, ...EXECUTION_RESULT_TOOLS, ...REPAIR_RESULT_TOOLS];
28
- export const VALIDATOR_TOOLS = ["read", "grep", "find", "ls", "bash", "write", WORKFLOW_DIAGRAM_TOOL, ...REVIEW_RESULT_TOOLS, ...VALIDATION_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];
29
30
 
30
31
 
31
32
  const PATH_SCOPED_TOOLS = new Set(["read", "grep", "find", "ls", "edit", "write"]);
@@ -92,18 +93,79 @@ function packageInstructionPath(candidate: string): boolean {
92
93
  || rel === "themes" || rel.startsWith("themes/");
93
94
  }
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
+
95
138
  function repoLockPathBlock(pathValue: unknown, cwd: string, tool: string): string | undefined {
96
139
  const root = repoLockRoot(cwd);
97
140
  const candidate = resolveCandidatePath(typeof pathValue === "string" && pathValue.trim() ? pathValue.trim() : ".", cwd);
98
141
  if (!pathInsideRoot(candidate, root)) {
99
- if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && (piRuntimeInstructionPath(candidate) || packageInstructionPath(candidate))) return undefined;
142
+ if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && (piRuntimeInstructionPath(candidate) || packageInstructionPath(candidate) || piCodingAgentDocsPath(candidate) || userInstalledSkillPath(candidate) || workflowStateReadPath(candidate) || piClipboardImageTempFile(candidate))) return undefined;
100
143
  if (candidate.startsWith("/private/tmp/") || candidate.startsWith("/tmp/") || candidate.startsWith("/var/tmp/")) return undefined;
101
- return `Repo Lock blocked path outside current repository: ${candidate} (repo root: ${root})`;
144
+ return "Path outside repository";
102
145
  }
103
- 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";
104
147
  return undefined;
105
148
  }
106
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
+
107
169
  function stripHereDocBodies(command: string): string {
108
170
  const lines = command.split("\n");
109
171
  const kept: string[] = [];
@@ -124,14 +186,89 @@ function stripUriTokens(command: string): string {
124
186
  }
125
187
 
126
188
  function bashPathCandidates(command: string): string[] {
127
- const trimmed = stripUriTokens(stripHereDocBodies(command)).trim();
189
+ const trimmed = stripUriTokens(stripHereDocBodies(stripQuotedSlashes(command))).trim();
128
190
  if (!trimmed) return [];
129
191
  return Array.from(trimmed.matchAll(/(?:^|[\s=:'"`])((?:\.{1,2}|~|\/)[^\s'"`;&|)]*)/g)).map((match) => match[1]).filter(Boolean);
130
192
  }
131
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
+
132
268
  function repoLockBashBlock(command: string, cwd: string): string | undefined {
133
269
  const root = repoLockRoot(cwd);
134
270
  const pathCandidates = bashPathCandidates(command);
271
+ const cpSourceOperands = simpleCpSourceOperands(command);
135
272
  for (const raw of pathCandidates) {
136
273
  if (raw === "." || raw === "./" || raw === "/") continue;
137
274
  const cleaned = raw.replace(/[),]+$/, "");
@@ -139,7 +276,13 @@ function repoLockBashBlock(command: string, cwd: string): string | undefined {
139
276
  if (cleaned.startsWith("/dev/")) continue;
140
277
  if (cleaned.startsWith("/tmp/") || cleaned.startsWith("/private/tmp/") || cleaned.startsWith("/var/tmp/")) continue;
141
278
  const candidate = resolveCandidatePath(cleaned, cwd);
142
- 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
+ }
143
286
  }
144
287
  return undefined;
145
288
  }
@@ -199,8 +342,36 @@ function isPlanMode(mode: WorkflowState["mode"]): boolean {
199
342
  return mode === "awaiting_plan_input" || mode === "awaiting_clarification" || mode === "planning" || mode === "plan_draft" || mode === "plan_approved";
200
343
  }
201
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
+
202
373
  function isValidatorMode(mode: WorkflowState["mode"]): boolean {
203
- return mode === "reviewing" || mode === "reviewed" || mode === "validating" || mode === "revalidating" || 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";
204
375
  }
205
376
 
206
377
  function isValidationResultMode(mode: WorkflowState["mode"]): boolean {
@@ -264,13 +435,58 @@ function stripSafePreamble(command: string): string {
264
435
  return command.replace(/^(?:set\s+[-+][euxo]+(?:\s+[^\n]*)?|export\s+\w+=["']?[^\n"']*["']?|\w+=\S+)\s*\n+/gm, "").trim() || command;
265
436
  }
266
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));
477
+ }
478
+
267
479
  function validatorSafeEvidenceBash(command: string): boolean {
268
480
  const trimmed = command.trim();
269
481
  if (!trimmed) return false;
270
482
  const cmd = stripSafePreamble(stripTimeoutPrefix(trimmed));
271
483
  if (isBlockedExecuteCommand(cmd)) return false;
272
484
  if (DESTRUCTIVE_WORD_RE.test(cmd)) return false;
273
- return true;
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;
274
490
  }
275
491
 
276
492
  function standardTodoTitleLooksGeneric(title: string): boolean {
@@ -297,7 +513,7 @@ function standardRequiredTodoMissing(state: WorkflowState, settings: ReturnType<
297
513
  }
298
514
 
299
515
  function planProgressRelevantWorkTool(tool: string, input: unknown): boolean {
300
- if (tool === "edit" || tool === "write") return true;
516
+ if (tool === "read" || tool === "grep" || tool === "find" || tool === "ls" || tool === "edit" || tool === "write" || tool === "subagent") return true;
301
517
  if (tool !== "bash") return false;
302
518
  const command = String((input as { command?: unknown } | undefined)?.command ?? "");
303
519
  return Boolean(command.trim()) && !standardSafeReadOnlyBash(command);
@@ -313,10 +529,22 @@ function currentPlanProgressStepNumber(state: WorkflowState): number | undefined
313
529
  }
314
530
 
315
531
  function planProgressToolRequiredBlock(state: WorkflowState, tool: string, input: unknown): string | undefined {
316
- if (state.mode !== "executing" && state.mode !== "repairing") return undefined;
317
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
+ }
318
543
  const stepNumber = currentPlanProgressStepNumber(state);
319
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.
320
548
  if (state.planProgressLastToolStatus === "active" && state.planProgressLastToolStep === stepNumber) return undefined;
321
549
  return `Plan execution ${tool} is blocked until workflow_progress({ step: ${stepNumber}, status: "active" }) is called for the current approved Plan step.`;
322
550
  }
@@ -346,35 +574,78 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
346
574
  }
347
575
 
348
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
+ }
349
582
  if (tool === "bash") {
350
583
  const command = String((event.input as { command?: unknown }).command ?? "");
351
- 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" };
352
586
  }
353
587
  return;
354
588
  }
355
589
 
356
- if (tool === STANDARD_HANDOFF_RESULT_TOOL && state.mode !== "standard") return { block: true, reason: "Standard handoff result is only available while Standard Mode is active." };
357
-
358
- if (tool === WORKFLOW_PLAN_RESULT_TOOL && state.mode !== "planning" && state.mode !== "executing" && state.mode !== "repairing") return { block: true, reason: `${tool} is only available during its planning phase.` };
359
- if (tool === MISSION_PLAN_RESULT_TOOL && state.mode !== "mission_planning") return { block: true, reason: `${tool} is only available during its planning phase.` };
360
- 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." };
361
- if (tool === WORKFLOW_EXECUTION_RESULT_TOOL && state.mode !== "executing") return { block: true, reason: "workflow_execution_result is only available during Plan execution." };
362
- if (tool === MISSION_MILESTONE_RESULT_TOOL && state.mode !== "mission_running") return { block: true, reason: "mission_milestone_result is only available during Mission execution." };
363
- if (tool === WORKFLOW_VALIDATION_RESULT_TOOL && !isValidationResultMode(state.mode)) return { block: true, reason: "workflow_validation_result is only available during validation phases." };
364
- 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." };
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
+ }
365
613
 
366
- if (tool === WORKFLOW_PROGRESS_TOOL && state.mode !== "executing" && state.mode !== "repairing") return { block: true, reason: "Plan step progress tracking is only available during Plan execution." };
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
+ }
367
629
 
368
630
  if (tool === "standard_todo") {
369
- if (state.mode !== "standard") return { block: true, reason: "Standard Mode To Do is only available while Standard Mode is active." };
370
- 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" };
371
632
  }
372
633
 
373
634
  if (state.mode === "standard" && tool !== "standard_todo" && standardRequiredTodoMissing(state, settings)) {
374
- 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` };
375
636
  if (tool === "bash") {
376
637
  const command = String((event.input as { command?: unknown }).command ?? "");
377
- 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." };
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.` };
645
+ if (tool === "bash") {
646
+ const command = String((event.input as { command?: unknown }).command ?? "");
647
+ if (!standardSafeReadOnlyBash(command))
648
+ return { block: true, reason: "Bash blocked — Standard Mode clarification is pending. Answer the clarification questions first." };
378
649
  }
379
650
  }
380
651
 
@@ -383,21 +654,31 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
383
654
 
384
655
  if (isPlanMode(state.mode)) {
385
656
  if (state.mode === "plan_approved" && state.approvedPlan) return;
386
- 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)" : ""}` };
387
- if (tool === "bash" && settings.safety.disableBashInPlanMode !== false) return { block: true, reason: `Workflow Plan Mode blocks bash. Allowed tools: ${PLAN_TOOLS.join(", ")}` };
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" };
388
669
  }
389
670
 
390
671
  if (isValidatorMode(state.mode)) {
391
- if (tool === "edit") return { block: true, reason: `Workflow Review/Validator Mode blocks ${tool}. Allowed tools: ${VALIDATOR_TOOLS.join(", ")}` };
672
+ if (tool === "edit" || tool === "write") return { block: true, reason: `${tool} not available in validation mode` };
392
673
  if (tool === "bash" && settings.safety.disableBashInValidatorMode !== false) {
393
674
  const command = String((event.input as { command?: unknown }).command ?? "");
394
- 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" };
395
676
  }
396
677
  }
397
678
 
398
- 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") {
399
680
  const command = String((event.input as { command?: unknown }).command ?? "");
400
- 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" };
401
682
  }
402
683
  });
403
684
 
@@ -406,7 +687,11 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
406
687
  const settings = loadWorkflowSettings(ctx.cwd);
407
688
 
408
689
  if (isSubagentWorker()) {
409
- 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 } };
410
695
  return;
411
696
  }
412
697
 
@@ -418,11 +703,17 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
418
703
  if (isPlanMode(state.mode) && settings.safety.disableBashInPlanMode !== false) {
419
704
  return { result: { output: `Workflow ${state.mode} blocks user bash: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
420
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
+ }
421
712
  if (isValidatorMode(state.mode) && !validatorSafeEvidenceBash(event.command)) {
422
713
  return { result: { output: `Workflow ${state.mode} blocks unsafe user bash: ${event.command}`, exitCode: 1, cancelled: false, truncated: false } };
423
714
  }
424
- if ((isExecutionMode(state.mode) || isPlanMode(state.mode) || isValidatorMode(state.mode)) && commandBlocked(event.command, ctx.cwd)) {
425
- 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 } };
426
717
  }
427
718
  });
428
719
  }
@@ -45,11 +45,14 @@ export function validationReportHasRepairableIssue(text?: string): boolean {
45
45
  .replace(/\bno automated repair is needed\b/g, " ")
46
46
  .replace(/\bno specific missing requirements? (?:is |are )?identified\b/g, " ")
47
47
  .replace(/\bmanual[-\s]only\b/g, " ");
48
- return /\b(needs? repair|needs? revision|repair pass|repairable (issue|failure|defect)|concrete (issue|failure|defect|regression)|blocking issues?|critical issues?|must fix|required (fixes?|actions?)|fixes required|remaining (fixes?|issues?|gaps?)|should be fixed before advancing|apply (the )?(two |[0-9]+ )?remaining fixes?|needs? to be (replaced|updated|expanded|corrected)|missing requirements?|not fully meet|does not fully meet|not (a )?full final artifact|acceptable as (a )?checkpoint baseline but not (a )?(full )?final artifact|unexpected changes?|regression introduced|build (failed|error)|type error|tests? failed|new lint error|incomplete (file|artifact|implementation|coverage)|persistent artifact|structured artifact|risk register artifact|artifact required|(?:produce|create|add|write) (a )?(structured |persistent )?(risk register )?artifact|missing (file|config|import|export|declaration|function|module|dependency))\b/.test(actionable);
48
+ return /\b(needs? repair|needs? revision|repair pass|repairable (issue|failure|defect)|concrete (issue|failure|defect|regression)|blocking issues?|critical issues?|must fix|required (fixes?|actions?)|fixes required|fix(?:es)? needed|fix\s*:\s*\S|one fix needed|remaining (fixes?|issues?|gaps?)|should be fixed before advancing|apply (the )?(two |[0-9]+ )?remaining fixes?|needs? to be (replaced|updated|expanded|corrected)|missing requirements?|not fully meet|does not fully meet|not (a )?full final artifact|acceptable as (a )?checkpoint baseline but not (a )?(full )?final artifact|unexpected changes?|regression introduced|build (failed|error)|type error|tests? failed|new lint error|incomplete (file|artifact|implementation|coverage)|persistent artifact|structured artifact|risk register artifact|artifact required|(?:produce|create|add|write) (a )?(structured |persistent )?(risk register )?artifact|missing (file|config|import|export|declaration|function|module|dependency)|add\s+\S+\s+(?:attribute|to\s+(?:the\s+)?form|to\s+(?:the\s+)?element)|change\s+\S+\s+to\s+\S+|update\s+\S+\s+to\s+\S+|single\s+(?:non[- ]destructive|safe|trivial)\s+(?:attribute\s+)?change|the fix is\s)\b/.test(actionable);
49
49
  }
50
50
 
51
51
  export function validationReportIsEvidenceGap(text?: string): boolean {
52
52
  const report = text ?? "";
53
+ // If the report body contains a concrete repairable issue, it is not
54
+ // purely an evidence gap — route to repair so the fix can be applied.
55
+ if (validationReportHasRepairableIssue(report)) return false;
53
56
  const evidenceGap = structuredValidationYes(report, "Evidence Gap");
54
57
  const repairable = structuredValidationYes(report, "Concrete Repairable Issue");
55
58
  if (evidenceGap === true && repairable !== true) return true;
@@ -58,17 +61,30 @@ export function validationReportIsEvidenceGap(text?: string): boolean {
58
61
  && !validationReportHasRepairableIssue(report);
59
62
  }
60
63
 
64
+ /**
65
+ * Patterns that indicate the report describes automatable evidence that was
66
+ * NOT gathered, rather than genuinely human-only verification. These must
67
+ * classify as repairable/evidence-acquisition failures, not manual_only.
68
+ */
69
+ const AUTOMATABLE_EVIDENCE_MISSING_RE = /\b(browser qa not performed|dev server not (run|started|launched)|localstorage not verified|automated runtime evidence missing|runtime checks? not (run|performed|executed)|preview server not (run|started)|app not launched|endpoint not (tested|verified|checked)|api not (tested|verified|checked)|server not (started|tested|verified)|e2e (test|check|suite) not run|integration test not run|smoke test not (run|performed)|not fully verified|not (fully |independently )?(verified|checked|tested|confirmed)|no (browser|headless|automated) (runner|test|check|verification)|(could not|cannot|unable to) (verify|check|test|confirm|run|start|launch|access)|(was |were )?not (attempted|performed|executed|run|gathered|available)|manual (qa|check|inspection|review) (is |may be )?(still )?required|evidence gaps?)\b/i;
70
+
61
71
  /**
62
72
  * Check whether a validation report represents only a manual/visual QA caveat
63
73
  * with no concrete repairable code issue.
74
+ *
75
+ * IMPORTANT: Reports that mention automatable evidence not being gathered
76
+ * (browser QA, dev server, localStorage, runtime checks) are NOT manual-only.
77
+ * They represent evidence-acquisition failures that should route to repair.
64
78
  */
65
79
  export function validationReportIsManualOnlyCaveat(text?: string): boolean {
66
80
  const report = text ?? "";
67
81
  const manual = structuredValidationYes(report, "Manual Verification Required");
68
82
  const repairable = structuredValidationYes(report, "Concrete Repairable Issue");
69
- if (manual === true && repairable === false) return true;
83
+ if (manual === true && repairable === false && !AUTOMATABLE_EVIDENCE_MISSING_RE.test(report)) return true;
70
84
  const normalized = report.toLowerCase();
71
85
  if (!normalized.trim()) return false;
86
+ // Automatable evidence patterns must not classify as manual_only
87
+ if (AUTOMATABLE_EVIDENCE_MISSING_RE.test(normalized)) return false;
72
88
  const manualCaveat = /(manual|visual|browser).{0,50}(verification|qa|inspection|confirmation)|visual[-\s]?verification caveat|pass with.{0,40}caveat|manual verification needed/.test(normalized);
73
89
  const noConcreteRepairableIssue = /no (actual |concrete )?(code |repairable )?(failure|failures|issue|issues|defect|defects)|no (code |repairable )?failures exist|no concrete (code |repairable )?issues?|only remaining validation item is manual|only incomplete item is manual|cannot be performed through (code )?repair|out of scope for (code )?repair/.test(normalized);
74
90
  return manualCaveat && noConcreteRepairableIssue && !validationReportHasRepairableIssue(normalized);
@@ -77,10 +93,36 @@ export function validationReportIsManualOnlyCaveat(text?: string): boolean {
77
93
  /**
78
94
  * Classify a validation failure as manual-only, repairable, or ambiguous.
79
95
  */
80
- export function classifyValidationFailure(verdict: WorkflowState["validationVerdict"], report: string): ValidationFailureClassification {
81
- if (validationReportIsManualOnlyCaveat(report)) return "manual_only";
96
+ export function classifyValidationFailure(
97
+ verdict: WorkflowState["validationVerdict"],
98
+ report: string,
99
+ opts?: { concreteRepairableIssue?: boolean; manualVerificationRequired?: boolean },
100
+ ): ValidationFailureClassification {
101
+ const manualField = structuredValidationYes(report, "Manual Verification Required");
102
+ const repairableField = structuredValidationYes(report, "Concrete Repairable Issue");
103
+
104
+ // Positive repairability signals win over manual caveats.
105
+ if (opts?.concreteRepairableIssue === true || repairableField === true) return "repairable";
106
+
107
+ // Automatable evidence not gathered is repairable/evidence-acquisition work,
108
+ // even if a legacy report also marks manual verification as required.
109
+ if (AUTOMATABLE_EVIDENCE_MISSING_RE.test(report)) return "repairable";
110
+
111
+ // Manual-only is valid only when no automatable or concrete repair signal exists.
112
+ if (opts?.manualVerificationRequired === true && opts?.concreteRepairableIssue === false) return "manual_only";
113
+ if (manualField === true && repairableField === false) return "manual_only";
114
+
115
+ // Evidence gaps without a concrete repairable issue are ambiguous, not repair loops.
82
116
  if (validationReportIsEvidenceGap(report)) return "ambiguous";
117
+
118
+ // FAIL or concrete repairable issues → repairable.
83
119
  if (verdict === "FAIL" || validationReportHasRepairableIssue(report)) return "repairable";
120
+
121
+ if (verdict === "PARTIAL PASS") {
122
+ if (validationReportIsManualOnlyCaveat(report)) return "manual_only";
123
+ return "ambiguous";
124
+ }
125
+
84
126
  return "ambiguous";
85
127
  }
86
128