@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.
- package/CHANGELOG.md +36 -0
- package/README.md +26 -17
- package/VERSION +1 -1
- package/agents/codebase-research.md +7 -5
- package/agents/general-worker.md +9 -7
- package/agents/implementation-planning.md +5 -3
- package/agents/quality-validation.md +9 -8
- package/agents/workflow-orchestrator.md +9 -7
- package/config/prompts/execute-approved-plan.md +12 -2
- package/config/prompts/mission-final-validation.md +38 -5
- package/config/prompts/mission-plan.md +17 -1
- package/config/prompts/mission-repair.md +16 -2
- package/config/prompts/mission-review-prompt.md +19 -6
- package/config/prompts/mission-run.md +18 -5
- package/config/prompts/validate-approved-plan.md +57 -3
- package/config/prompts/workflow-plan-prompt.md +11 -1
- package/config/prompts/workflow-repair.md +18 -2
- package/config/prompts/workflow-reviewer-prompt.md +25 -9
- package/config/prompts/workflow-summary.md +1 -4
- package/config/workflow-settings.example.json +13 -11
- package/docs/assets/mediadatafusion-logo.png +0 -0
- package/docs/assets/pi-workflow-suite-demo.gif +0 -0
- package/docs/assets/pi-workflow-suite-demo.mp4 +0 -0
- package/docs/assets/pi-workflow-suite-header.png +0 -0
- package/docs/assets/pi-workflow-suite-video-thumb.png +0 -0
- package/docs/assets/readme-link-commands.svg +10 -0
- package/docs/assets/readme-link-install.svg +10 -0
- package/docs/assets/readme-link-quick-start.svg +10 -0
- package/docs/assets/readme-link-settings.svg +10 -0
- package/docs/assets/screenshots/.gitkeep +1 -0
- package/docs/assets/screenshots/00-mission-home.png +0 -0
- package/docs/assets/screenshots/01-startup-Logo.png +0 -0
- package/docs/assets/screenshots/02-theme-settings.png +0 -0
- package/docs/assets/screenshots/03-GlobalSafetySettings.png +0 -0
- package/docs/assets/screenshots/04-SharedSubAgentsSettings.png +0 -0
- package/docs/assets/screenshots/05-mission-mode.png +0 -0
- package/docs/assets/screenshots/06-diagram-mermaid.png +0 -0
- package/extensions/subagent/index.ts +41 -18
- package/extensions/subagent/repolock-guard.ts +224 -4
- package/extensions/subagent/runner.ts +136 -12
- package/extensions/workflow-model-router.ts +124 -41
- package/extensions/workflow-modes.ts +3791 -967
- package/extensions/workflow-settings-capabilities.ts +10 -0
- package/extensions/workflow-state.ts +77 -10
- package/extensions/workflow-subagent-policy.ts +13 -1
- package/extensions/workflow-summary.ts +8 -19
- package/extensions/workflow-tool-guard.ts +326 -35
- package/extensions/workflow-validation-classifier.ts +46 -4
- package/extensions/workflow-web-tools.ts +361 -1
- package/package.json +9 -5
- package/scripts/audit-live.sh +1 -1
- package/scripts/build-package-export.mjs +8 -13
- package/scripts/check-clean-release-tree.sh +3 -2
- package/scripts/check-package-media.mjs +78 -0
- package/scripts/install-to-live.sh +2 -0
- package/scripts/package-media-config.mjs +28 -0
- package/scripts/prepare-package-readme.mjs +19 -18
- package/scripts/quarantine-live-junk.sh +1 -1
- package/scripts/verify-live.sh +9 -1
- package/skills/implementation-planning/SKILL.md +1 -1
- package/skills/safe-execution/SKILL.md +1 -1
- 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
|
|
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
|
|
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
|
|
144
|
+
return "Path outside repository";
|
|
102
145
|
}
|
|
103
|
-
if ((tool === "edit" || tool === "write") && protectedRepoPath(candidate, root)) return
|
|
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))
|
|
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 === "
|
|
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
|
|
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 (
|
|
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
|
|
357
|
-
|
|
358
|
-
if (tool === WORKFLOW_PLAN_RESULT_TOOL && state.mode !== "planning" && state.mode !== "executing" && state.mode !== "repairing") return { block: true, reason: `${tool}
|
|
359
|
-
if (tool === MISSION_PLAN_RESULT_TOOL && state.mode !== "mission_planning") return { block: true, reason:
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 ===
|
|
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: "
|
|
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"
|
|
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: "
|
|
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:
|
|
387
|
-
if (tool === "bash" && settings.safety.disableBashInPlanMode !== false) return { block: true, reason:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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(
|
|
81
|
-
|
|
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
|
|