@mediadatafusion/pi-workflow-suite 0.0.9 → 0.0.11

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.
@@ -204,7 +204,8 @@ export function formatAnswersForPlanner(questions: ClarificationQuestion[], answ
204
204
 
205
205
  export function planValidationStatusForVerdict(verdict: WorkflowState["validationVerdict"]): PlanValidationStatus {
206
206
  if (verdict === "PASS") return "pass";
207
- if (verdict === "UNKNOWN" || verdict === "PARTIAL PASS") return "unknown";
207
+ if (verdict === "PARTIAL PASS") return "partial pass";
208
+ if (verdict === "UNKNOWN") return "unknown";
208
209
  return "fail";
209
210
  }
210
211
 
@@ -93,7 +93,7 @@ export interface StandardRuntimeState {
93
93
 
94
94
  export type PlanLifecycleStatus = "planning" | "awaiting_clarification" | "plan_ready" | "approved" | "reviewing" | "executing" | "validating" | "repairing" | "revalidating" | "completed" | "blocked";
95
95
  export type PlanStepStatus = "pending" | "active" | "completed" | "failed" | "blocked" | "skipped";
96
- export type PlanValidationStatus = "pending" | "running" | "pass" | "fail" | "unknown";
96
+ export type PlanValidationStatus = "pending" | "running" | "pass" | "partial pass" | "fail" | "unknown";
97
97
 
98
98
  export interface PlanProgressStep {
99
99
  id: string;
@@ -163,12 +163,32 @@ export interface CompletedPlanSummary {
163
163
  finalReport?: string;
164
164
  }
165
165
 
166
+ export interface BlockedPlanResumeSnapshot {
167
+ task?: string;
168
+ originalTask?: string;
169
+ approvedPlan?: string;
170
+ planHistoryId?: string;
171
+ approvedPlanHistoryId?: string;
172
+ executionSummary?: string;
173
+ validationReport?: string;
174
+ validationVerdict?: "PASS" | "PARTIAL PASS" | "FAIL" | "UNKNOWN";
175
+ lastValidationFailure?: string;
176
+ lastRepairAttempt?: string;
177
+ repairHistory?: WorkflowRepairHistoryEntry[];
178
+ lastRepairStatus?: "none" | "running" | "completed" | "failed" | "blocked";
179
+ currentValidationRetry?: number;
180
+ workflowValidationRetryCount?: number;
181
+ planRuntime?: PlanRuntimeState;
182
+ planProgress?: PlanProgressState;
183
+ }
184
+
166
185
  export interface WorkflowFinalStopSummary {
167
186
  stoppedAt: string;
168
187
  kind: "plan" | "mission";
169
188
  status: "completed" | "blocked";
170
189
  title: string;
171
190
  summary: string;
191
+ blockedPlanSnapshot?: BlockedPlanResumeSnapshot;
172
192
  }
173
193
 
174
194
  export interface CompletedMissionSummary {
@@ -234,9 +254,20 @@ export interface WorkflowState {
234
254
  lastRepairAttempt?: string;
235
255
  repairHistory?: WorkflowRepairHistoryEntry[];
236
256
  lastRepairStatus?: "none" | "running" | "completed" | "failed" | "blocked";
257
+ concreteRepairableIssue?: boolean;
258
+ manualVerificationRequired?: boolean;
259
+ evidenceGap?: boolean;
260
+ lastValidationCompletedAt?: string;
237
261
  planStepValidationIndex?: number;
262
+ planExecutionStepIndex?: number;
238
263
  planRuntime?: PlanRuntimeState;
239
264
  planProgress?: PlanProgressState;
265
+ planProgressLastToolStep?: number;
266
+ planProgressLastToolStatus?: PlanStepStatus;
267
+ planProgressLastToolAt?: string;
268
+ planTokensUsed?: number;
269
+ missionTokensUsed?: number;
270
+ standardTokensUsed?: number;
240
271
  standardRuntime?: StandardRuntimeState;
241
272
  standardTodo?: StandardTodoState;
242
273
  standardLastAutoCheckAt?: string;
@@ -290,6 +321,15 @@ export interface SavedWorkflowPlan {
290
321
  finalReport?: string;
291
322
  modelsUsed?: WorkflowState["modelsUsed"];
292
323
  subagents?: Record<string, unknown>;
324
+ planProgress?: WorkflowState["planProgress"];
325
+ planRuntime?: WorkflowState["planRuntime"];
326
+ planExecutionStepIndex?: number;
327
+ planStepValidationIndex?: number;
328
+ currentValidationRetry?: number;
329
+ workflowValidationRetryCount?: number;
330
+ repairRetryState?: WorkflowState["repairRetryState"];
331
+ repairHistory?: WorkflowState["repairHistory"];
332
+ reviewHistory?: WorkflowState["reviewHistory"];
293
333
  }
294
334
 
295
335
  export interface PlanSavingOptions {
@@ -384,6 +424,9 @@ export interface MissionState {
384
424
  reviewHistory?: WorkflowReviewHistoryEntry[];
385
425
  reviewRepairInProgress?: boolean;
386
426
  lastValidationResult?: string;
427
+ concreteRepairableIssue?: boolean;
428
+ manualVerificationRequired?: boolean;
429
+ evidenceGap?: boolean;
387
430
  modelsUsed: Record<string, string>;
388
431
  subagentsUsed: string[];
389
432
  approvalRequired: boolean;
@@ -534,6 +577,15 @@ export function saveWorkflowPlan(state: WorkflowState, options: PlanSavingOption
534
577
  finalReport: options.finalReport?.trim() ? (redactSecrets(compact(options.finalReport, 5000)) ?? compact(options.finalReport, 5000)) : undefined,
535
578
  modelsUsed: state.modelsUsed,
536
579
  subagents: options.subagents,
580
+ planProgress: state.planProgress,
581
+ planRuntime: state.planRuntime,
582
+ planExecutionStepIndex: state.planExecutionStepIndex,
583
+ planStepValidationIndex: state.planStepValidationIndex,
584
+ currentValidationRetry: state.currentValidationRetry,
585
+ workflowValidationRetryCount: state.workflowValidationRetryCount,
586
+ repairRetryState: state.repairRetryState,
587
+ repairHistory: state.repairHistory,
588
+ reviewHistory: state.reviewHistory,
537
589
  };
538
590
 
539
591
  writeFileSync(LATEST_PLAN_FILE, JSON.stringify(record, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
@@ -713,8 +765,10 @@ function activeElapsedMs(startedAt: string | null | undefined, nowMs: number, la
713
765
  const parsed = Date.parse(startedAt ?? "");
714
766
  if (!Number.isFinite(parsed)) return 0;
715
767
  const updated = Date.parse(lastUpdatedAt ?? "");
716
- const end = parsed < RUNTIME_SESSION_STARTED_AT_MS && Number.isFinite(updated) && updated < RUNTIME_SESSION_STARTED_AT_MS
717
- ? Math.max(parsed, updated)
768
+ const end = parsed < RUNTIME_SESSION_STARTED_AT_MS
769
+ ? (Number.isFinite(updated) && updated < RUNTIME_SESSION_STARTED_AT_MS
770
+ ? Math.max(parsed, updated)
771
+ : RUNTIME_SESSION_STARTED_AT_MS)
718
772
  : nowMs;
719
773
  return Math.max(0, end - parsed);
720
774
  }
@@ -769,7 +823,9 @@ export function planActiveRuntimeMs(state: WorkflowState, now = new Date()): num
769
823
  export function planWallClockAgeMs(state: WorkflowState, now = new Date()): number {
770
824
  const start = Date.parse(state.planRuntime?.createdAt ?? "");
771
825
  if (!Number.isFinite(start)) return 0;
772
- return Math.max(0, now.getTime() - start);
826
+ const terminalTimestamp = planRuntimeCounterState(state) === "stopped" ? state.updatedAt : undefined;
827
+ const end = terminalTimestamp ? Date.parse(terminalTimestamp) : now.getTime();
828
+ return Math.max(0, (Number.isFinite(end) ? end : now.getTime()) - start);
773
829
  }
774
830
 
775
831
  export function applyStandardRuntimeAccounting(previous: WorkflowState | undefined, state: WorkflowState, now = new Date()): WorkflowState {
@@ -825,7 +881,9 @@ export function standardActiveRuntimeMs(state: WorkflowState, now = new Date()):
825
881
  export function standardWallClockAgeMs(state: WorkflowState, now = new Date()): number {
826
882
  const start = Date.parse(state.standardRuntime?.createdAt ?? "");
827
883
  if (!Number.isFinite(start)) return 0;
828
- return Math.max(0, now.getTime() - start);
884
+ const terminalTimestamp = standardRuntimeCounterState(state) === "stopped" ? state.updatedAt : undefined;
885
+ const end = terminalTimestamp ? Date.parse(terminalTimestamp) : now.getTime();
886
+ return Math.max(0, (Number.isFinite(end) ? end : now.getTime()) - start);
829
887
  }
830
888
 
831
889
  export function applyMissionRuntimeAccounting(previous: MissionState | undefined, mission: MissionState, now = new Date()): MissionState {
@@ -858,7 +916,7 @@ export function applyMissionRuntimeAccounting(previous: MissionState | undefined
858
916
  lastResumedAt: mission.lastResumedAt ?? nowIso,
859
917
  };
860
918
  } else if (nextActive && previousStartedAt) {
861
- next = { ...next, activeRunStartedAt: previousStartedAt };
919
+ next = { ...next, activeRunStartedAt: Date.parse(previousStartedAt) < RUNTIME_SESSION_STARTED_AT_MS ? nowIso : previousStartedAt };
862
920
  } else if (!nextActive) {
863
921
  next = { ...next, activeRunStartedAt: null };
864
922
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
- import { isAbsolute, resolve } from "node:path";
3
+ import { isAbsolute, resolve, join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
6
  import { loadWorkflowSettings } from "./workflow-model-router.js";
6
7
  import type { WorkflowState } from "./workflow-state.js";
@@ -22,9 +23,9 @@ export const EXECUTION_RESULT_TOOLS = [WORKFLOW_EXECUTION_RESULT_TOOL, MISSION_M
22
23
  export const VALIDATION_RESULT_TOOLS = [WORKFLOW_VALIDATION_RESULT_TOOL];
23
24
  export const REPAIR_RESULT_TOOLS = [WORKFLOW_REPAIR_RESULT_TOOL];
24
25
  export const STANDARD_RESULT_TOOLS = [STANDARD_HANDOFF_RESULT_TOOL];
25
- export const BASE_EXECUTE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash", WORKFLOW_PROGRESS_TOOL, WORKFLOW_DIAGRAM_TOOL];
26
- export const EXECUTE_TOOLS = [...BASE_EXECUTE_TOOLS, ...EXECUTION_RESULT_TOOLS, ...REPAIR_RESULT_TOOLS];
27
- export const VALIDATOR_TOOLS = ["read", "grep", "find", "ls", "bash", WORKFLOW_DIAGRAM_TOOL, ...REVIEW_RESULT_TOOLS, ...VALIDATION_RESULT_TOOLS];
26
+ export const BASE_EXECUTE_TOOLS = ["read", "grep", "find", "ls", "edit", "write", "bash", WORKFLOW_DIAGRAM_TOOL];
27
+ export const EXECUTE_TOOLS = [...BASE_EXECUTE_TOOLS, WORKFLOW_PROGRESS_TOOL, ...EXECUTION_RESULT_TOOLS, ...REPAIR_RESULT_TOOLS];
28
+ export const VALIDATOR_TOOLS = ["read", "grep", "find", "ls", "bash", "write", WORKFLOW_DIAGRAM_TOOL, ...REVIEW_RESULT_TOOLS, ...VALIDATION_RESULT_TOOLS];
28
29
 
29
30
 
30
31
  const PATH_SCOPED_TOOLS = new Set(["read", "grep", "find", "ls", "edit", "write"]);
@@ -58,33 +59,87 @@ function pathInsideRoot(candidate: string, root: string): boolean {
58
59
  return candidate === root || candidate.startsWith(`${root}/`);
59
60
  }
60
61
 
61
- function piRuntimeRoot(): string {
62
- return safeRealpath(getAgentDir());
62
+ function repoLockRoot(cwd: string): string {
63
+ return process.env.PI_WORKFLOW_REPO_LOCK_ENABLED === "1" && process.env.PI_WORKFLOW_REPO_LOCK_ROOT
64
+ ? safeRealpath(process.env.PI_WORKFLOW_REPO_LOCK_ROOT)
65
+ : repoRootForCwd(cwd);
63
66
  }
64
67
 
65
- function pathInsideRepoOrPiRuntime(candidate: string, root: string): boolean {
66
- const piRoot = piRuntimeRoot();
67
- return pathInsideRoot(candidate, root) || pathInsideRoot(candidate, piRoot);
68
+ function protectedRepoPath(candidate: string, root: string): boolean {
69
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
70
+ return rel === ".pi" || rel.startsWith(".pi/");
68
71
  }
69
72
 
70
- function repoLockPathBlock(pathValue: unknown, cwd: string): string | undefined {
71
- const root = repoRootForCwd(cwd);
73
+ function piRuntimeInstructionPath(candidate: string): boolean {
74
+ const root = safeRealpath(getAgentDir());
75
+ if (!pathInsideRoot(candidate, root)) return false;
76
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
77
+ return rel === "skills" || rel.startsWith("skills/")
78
+ || rel === "agents" || rel.startsWith("agents/")
79
+ || rel === "config/prompts" || rel.startsWith("config/prompts/")
80
+ || rel === "prompts" || rel.startsWith("prompts/")
81
+ || rel === "themes" || rel.startsWith("themes/");
82
+ }
83
+
84
+ function packageInstructionPath(candidate: string): boolean {
85
+ const root = safeRealpath(join(dirname(fileURLToPath(import.meta.url)), ".."));
86
+ if (!pathInsideRoot(candidate, root)) return false;
87
+ const rel = candidate === root ? "" : candidate.slice(root.length + 1);
88
+ return rel === "skills" || rel.startsWith("skills/")
89
+ || rel === "agents" || rel.startsWith("agents/")
90
+ || rel === "config/prompts" || rel.startsWith("config/prompts/")
91
+ || rel === "prompts" || rel.startsWith("prompts/")
92
+ || rel === "themes" || rel.startsWith("themes/");
93
+ }
94
+
95
+ function repoLockPathBlock(pathValue: unknown, cwd: string, tool: string): string | undefined {
96
+ const root = repoLockRoot(cwd);
72
97
  const candidate = resolveCandidatePath(typeof pathValue === "string" && pathValue.trim() ? pathValue.trim() : ".", cwd);
73
- if (!pathInsideRepoOrPiRuntime(candidate, root)) return `Repo Lock blocked path outside current repository or Pi runtime: ${candidate} (repo root: ${root}; Pi runtime: ${piRuntimeRoot()})`;
98
+ if (!pathInsideRoot(candidate, root)) {
99
+ if ((tool === "read" || tool === "grep" || tool === "find" || tool === "ls") && (piRuntimeInstructionPath(candidate) || packageInstructionPath(candidate))) return undefined;
100
+ 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})`;
102
+ }
103
+ if ((tool === "edit" || tool === "write") && protectedRepoPath(candidate, root)) return `Repo Lock blocked ${tool} for protected project control path: ${candidate}`;
74
104
  return undefined;
75
105
  }
76
106
 
107
+ function stripHereDocBodies(command: string): string {
108
+ const lines = command.split("\n");
109
+ const kept: string[] = [];
110
+ for (let i = 0; i < lines.length; i++) {
111
+ const line = lines[i];
112
+ kept.push(line);
113
+ const match = line.match(/<<[-]?\s*['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?/);
114
+ if (!match) continue;
115
+ const marker = match[1];
116
+ i++;
117
+ while (i < lines.length && lines[i].trim() !== marker) i++;
118
+ }
119
+ return kept.join("\n");
120
+ }
121
+
122
+ function stripUriTokens(command: string): string {
123
+ return command.replace(/\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s'"`;&|)]*/g, " ");
124
+ }
125
+
126
+ function bashPathCandidates(command: string): string[] {
127
+ const trimmed = stripUriTokens(stripHereDocBodies(command)).trim();
128
+ if (!trimmed) return [];
129
+ return Array.from(trimmed.matchAll(/(?:^|[\s=:'"`])((?:\.{1,2}|~|\/)[^\s'"`;&|)]*)/g)).map((match) => match[1]).filter(Boolean);
130
+ }
131
+
77
132
  function repoLockBashBlock(command: string, cwd: string): string | undefined {
78
- const trimmed = command.trim();
79
- if (!trimmed) return undefined;
80
- const root = repoRootForCwd(cwd);
81
- const pathCandidates = Array.from(trimmed.matchAll(/(?:^|[\s=:'"`])((?:\.{1,2}|~|\/)[^\s'"`;&|)]*)/g)).map((match) => match[1]).filter(Boolean);
133
+ const root = repoLockRoot(cwd);
134
+ const pathCandidates = bashPathCandidates(command);
82
135
  for (const raw of pathCandidates) {
83
- if (raw === "." || raw === "./") continue;
136
+ if (raw === "." || raw === "./" || raw === "/") continue;
84
137
  const cleaned = raw.replace(/[),]+$/, "");
85
138
  if (!cleaned || cleaned.startsWith("./node_modules/.bin")) continue;
139
+ if (cleaned.startsWith("/dev/")) continue;
140
+ if (cleaned.startsWith("/tmp/") || cleaned.startsWith("/private/tmp/") || cleaned.startsWith("/var/tmp/")) continue;
86
141
  const candidate = resolveCandidatePath(cleaned, cwd);
87
- if (!pathInsideRepoOrPiRuntime(candidate, root)) return `Repo Lock blocked bash path outside current repository or Pi runtime: ${cleaned} -> ${candidate} (repo root: ${root}; Pi runtime: ${piRuntimeRoot()})`;
142
+ if (!pathInsideRoot(candidate, root)) return `Repo Lock blocked bash path outside current repository: ${cleaned} -> ${candidate} (repo root: ${root})`;
88
143
  }
89
144
  return undefined;
90
145
  }
@@ -107,6 +162,26 @@ const BLOCKED_EXECUTE_BASH: RegExp[] = [
107
162
  /\bpnpm\s+add\b/i,
108
163
  /\byarn\s+add\b/i,
109
164
  /\bpip\s+install\b/i,
165
+ /\bpip3?\s+install\b/i,
166
+ /\bbundle\s+install\b/i,
167
+ /\bgem\s+install\b/i,
168
+ /\bcargo\s+install\b/i,
169
+ /\bgo\s+(?:get|install)\b/i,
170
+ /\bdeno\s+(?:install|add|cache)\b/i,
171
+ /\bcomposer\s+(?:install|require|update)\b/i,
172
+ /\bmix\s+(?:deps\.get|deps\.compile)\b/i,
173
+ /\bbrew\s+install\b/i,
174
+ /\bapt\s+(?:install|get\s+install)\b/i,
175
+ /\byum\s+install\b/i,
176
+ /\bdnf\s+install\b/i,
177
+ /\bapk\s+add\b/i,
178
+ /\bnuget\s+install\b/i,
179
+ /\bdotnet\s+(?:add\s+package|tool\s+install|restore)\b/i,
180
+ /\bcabal\s+(?:install|update)\b/i,
181
+ /\bstack\s+(?:install|update)\b/i,
182
+ /\bconan\s+install\b/i,
183
+ /\bvcpkg\s+install\b/i,
184
+ /\bcoursier\s+(?:install|fetch)\b/i,
110
185
  /\bcurl\b[^\n]*\|\s*sh\b/i,
111
186
  /\bwget\b[^\n]*\|\s*sh\b/i,
112
187
  /\bvercel\s+deploy\b/i,
@@ -125,7 +200,7 @@ function isPlanMode(mode: WorkflowState["mode"]): boolean {
125
200
  }
126
201
 
127
202
  function isValidatorMode(mode: WorkflowState["mode"]): boolean {
128
- return mode === "reviewing" || mode === "reviewed" || mode === "validating" || mode === "revalidating" || mode === "validated" || mode === "mission_validating" || mode === "mission_revalidating" || mode === "mission_final_validating";
203
+ return mode === "reviewing" || mode === "reviewed" || mode === "validating" || mode === "revalidating" || mode === "mission_validating" || mode === "mission_revalidating" || mode === "mission_final_validating";
129
204
  }
130
205
 
131
206
  function isValidationResultMode(mode: WorkflowState["mode"]): boolean {
@@ -140,9 +215,17 @@ function isSubagentWorker(): boolean {
140
215
  return process.env.PI_SUBAGENT_WORKER === "1";
141
216
  }
142
217
 
218
+ const PACKAGE_INSTALL_RE = /\b(?:npm\s+install|pnpm\s+add|yarn\s+add|pip3?\s+install|bundle\s+install|gem\s+install|cargo\s+install|go\s+(?:get|install)|deno\s+(?:install|add|cache)|composer\s+(?:install|require|update)|mix\s+deps\.(?:get|compile)|brew\s+install|apt(?:-get)?\s+install|yum\s+install|dnf\s+install|apk\s+add|nuget\s+install|dotnet\s+(?:add\s+package|tool\s+install|restore)|cabal\s+(?:install|update)|stack\s+(?:install|update)|conan\s+install|vcpkg\s+install|coursier\s+(?:install|fetch))\b/i;
219
+
220
+ function isPackageInstallCommand(command: string): boolean {
221
+ return PACKAGE_INSTALL_RE.test(command);
222
+ }
223
+
143
224
  function commandBlocked(command: string, cwd?: string): boolean {
144
225
  const settings = loadWorkflowSettings(cwd);
145
- return settings.safety.blockDestructiveCommands !== false && isBlockedExecuteCommand(command);
226
+ if (settings.safety.blockDestructiveCommands === false) return false;
227
+ if (isPackageInstallCommand(command) && settings.safety.allowPackageInstallInExecution !== false) return false;
228
+ return isBlockedExecuteCommand(command);
146
229
  }
147
230
 
148
231
  function standardTodoMode(settings: ReturnType<typeof loadWorkflowSettings>): "off" | "manual" | "auto" | "required" {
@@ -162,17 +245,32 @@ function standardTaskLooksSubstantive(task: string | undefined): boolean {
162
245
  return text.length >= 8 || text.split(/\s+/).filter(Boolean).length >= 2;
163
246
  }
164
247
 
165
- function standardSafeReadOnlyBash(command: string): boolean {
248
+ function stripTimeoutPrefix(command: string): string {
249
+ return command.replace(/^timeout\s+\d+[smhd]?\s+/, "").trim() || command;
250
+ }
251
+
252
+ const DESTRUCTIVE_WORD_RE = /\b(?:install|add|update|upgrade|publish|deploy|push|checkout|switch|commit|merge|rebase|stash|tag|apply|am|restore|sed\s+-i|perl\s+-pi|chmod|chown|curl\s.*\|\s*(?:sh|bash)|wget\s.*\|\s*(?:sh|bash))\b/i;
253
+
254
+ const SAFE_READ_ONLY_COMMANDS_RE = /^(?:git\s+(?:status|log|diff|show|branch|rev-parse|ls-files|describe|remote|tag|shortlog|count-objects|blame|name-rev)\b|cat\b|head\b|tail\b|less\b|more\b|wc\b|file\b|stat\b|which\b|where\b|command\s+-v\b|type\b|echo\b|printf\b|printenv\b|env\b|uname\b|date\b|id\b|whoami\b|hostname\b|pwd\b|ls\b|du\b|df\b|diff\b|comm\b|sort\b|uniq\b|cut\b|tr\b|awk\b|jq\b|yq\b|xq\b|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|test|lint|typecheck|type-check|check[\s:]?\w*|dev|start|preview|serve|watch|format|analyze|compile|ci|validate|verify|coverage|bench|benchmark|bundle|pack|dist|static|docs|doc|stylelint|e2e|integration|unit)\b|(?:npm|pnpm|yarn|bun)\s+(?:exec|info|ls|list|query|outdated|why|view|pack\s+--dry-run)\b|npx\s+(?:serve|http-server|lite-server|tsc|vite|eslint|prettier|vitest|jest|mocha|cypress|playwright|webpack|rollup|parcel|turbo|nx|ts-node|tsx|esbuild|swc|babel|stylelint|biome|rome|knip|typedoc|compodoc|angular-cli|react-scripts|next|nuxt|remix|astro|svelte-kit)\b|pnpm\s+(?:exec|dlx)\s+\w+\b|bun\s+(?:test|check|build|run)\b|deno\s+(?:check|test|build|lint|task|info|doc|compile|fmt|eval|cache)\b|cargo\s+(?:build|test|check|clippy|doc|bench|run|metadata|locate-project|tree|version)\b|(?:rustc|rustup)\s+(?:--version|--print|which)\b|go\s+(?:build|test|vet|run|doc|list|mod\s+(?:verify|tidy|graph|download|why))\b|python3?\s+(?:--version|-V|-c\b|-m\s+(?:pytest|unittest|mypy|pylint|flake8|black|isort|ruff|json\.tool|compileall|bandit|pyright|http\.server|html\.parser|html))\b|pip3?\s+(?:list|show|check|debug|index\s+versions)\b|tsc\b|node\s+(?:--version|-v|--check|-c|-e|--eval)\b|make\s+(?:build|test|check|lint|all|verify|docs|format|static|analyze)\b|cmake\s+(?:--build|--version)\b|(?:dotnet|msbuild)\s+(?:build|test|restore|check|format|lint|pack)\b|(?:gradle|\.\/gradlew|gradlew\.bat)\s+(?:build|test|check|compile|lint|dependencies|projects|tasks)\b|mvn\s+(?:compile|test|verify|checkstyle|pmd|versions:display|dependency:tree|dependency:list)\b|(?:swift|swiftc)\s+(?:build|test|package\s+(?:describe|dump-package))\b|(?:bundle|gem)\s+(?:exec|list|check|info|query)\b|rake\s+(?:test|spec|lint|check|notes|stats|about)\b|php\s+(?:--version|-v|-l)\b|(?:php\s+)?artisan\s+(?:--version|route:list|config:show|env)\b|composer\s+(?:validate|check|show|outdated|info|diagnose)\b|mix\s+(?:test|compile|lint|format|docs)\b|bazel\s+(?:build|test|query|cquery|info|version)\b|buck\s+(?:build|test|query|audit)\b|curl\s+(?:-[^\s]*[sSfIv][^\s]*\s+)+(?:https?:\/\/|localhost|\$)|kill\s+\$!\b|kill\s+-0\s+\$\w+\b|wait\s+\$!\b|wait\s+\$\w+\b|sleep\s+[0-9.]+[smhd]?\b|ps\s+(?:aux?|-[a-z]*[eE][a-z]*|-[a-z]*[pP][a-z]*)\b|pgrep\s+-\w+\s+\w+|true\b|false\b|\.\s*\/node_modules\/\.bin\/\S+\b)/i;
255
+
256
+ export function standardSafeReadOnlyBash(command: string): boolean {
166
257
  const trimmed = command.trim();
167
258
  if (!trimmed || isBlockedExecuteCommand(trimmed)) return false;
168
- return /^(?:git\s+(?:status|log|diff|show|branch|rev-parse)\b|python3?\s+-m\s+json\.tool\b|npm\s+run\s+(?:lint|test)\b|npx\s+tsc\s+--noEmit\b|tsc\s+--noEmit\b)/i.test(trimmed);
259
+ const cmd = stripTimeoutPrefix(trimmed);
260
+ return SAFE_READ_ONLY_COMMANDS_RE.test(cmd);
261
+ }
262
+
263
+ function stripSafePreamble(command: string): string {
264
+ return command.replace(/^(?:set\s+[-+][euxo]+(?:\s+[^\n]*)?|export\s+\w+=["']?[^\n"']*["']?|\w+=\S+)\s*\n+/gm, "").trim() || command;
169
265
  }
170
266
 
171
267
  function validatorSafeEvidenceBash(command: string): boolean {
172
268
  const trimmed = command.trim();
173
- if (!trimmed || isBlockedExecuteCommand(trimmed)) return false;
174
- if (/\b(?:install|add|update|upgrade|publish|deploy|push|reset|clean|checkout|switch|commit|merge|rebase|stash|tag|apply|am|restore|rm|mv|cp|mkdir|touch|sed\s+-i|perl\s+-pi|tee|chmod|chown|kill|open)\b/i.test(trimmed)) return false;
175
- return /^(?:git\s+(?:status|log|diff|show|branch|rev-parse|ls-files)\b|npm\s+run\s+(?:typecheck|check:ts|lint|test|build)\b|npx\s+tsc\s+--noEmit\b|tsc\s+--noEmit\b|python3?\s+-m\s+json\.tool\b)/i.test(trimmed);
269
+ if (!trimmed) return false;
270
+ const cmd = stripSafePreamble(stripTimeoutPrefix(trimmed));
271
+ if (isBlockedExecuteCommand(cmd)) return false;
272
+ if (DESTRUCTIVE_WORD_RE.test(cmd)) return false;
273
+ return true;
176
274
  }
177
275
 
178
276
  function standardTodoTitleLooksGeneric(title: string): boolean {
@@ -198,26 +296,42 @@ function standardRequiredTodoMissing(state: WorkflowState, settings: ReturnType<
198
296
  && standardTaskLooksSubstantive(task);
199
297
  }
200
298
 
299
+ function planProgressRelevantWorkTool(tool: string, input: unknown): boolean {
300
+ if (tool === "edit" || tool === "write") return true;
301
+ if (tool !== "bash") return false;
302
+ const command = String((input as { command?: unknown } | undefined)?.command ?? "");
303
+ return Boolean(command.trim()) && !standardSafeReadOnlyBash(command);
304
+ }
305
+
306
+ function currentPlanProgressStepNumber(state: WorkflowState): number | undefined {
307
+ const steps = state.planProgress?.steps ?? [];
308
+ if (!steps.length) return undefined;
309
+ if (steps.every((step) => step.status === "completed" || step.status === "skipped")) return undefined;
310
+ const activeIndex = steps.findIndex((step) => step.status === "active");
311
+ const fallbackIndex = Math.max(0, Math.min(steps.length - 1, Math.floor(state.planProgress?.currentStepIndex ?? 0)));
312
+ return (activeIndex >= 0 ? activeIndex : fallbackIndex) + 1;
313
+ }
314
+
315
+ function planProgressToolRequiredBlock(state: WorkflowState, tool: string, input: unknown): string | undefined {
316
+ if (state.mode !== "executing" && state.mode !== "repairing") return undefined;
317
+ if (!planProgressRelevantWorkTool(tool, input)) return undefined;
318
+ const stepNumber = currentPlanProgressStepNumber(state);
319
+ if (!stepNumber) return undefined;
320
+ if (state.planProgressLastToolStatus === "active" && state.planProgressLastToolStep === stepNumber) return undefined;
321
+ return `Plan execution ${tool} is blocked until workflow_progress({ step: ${stepNumber}, status: "active" }) is called for the current approved Plan step.`;
322
+ }
323
+
201
324
  export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowState): void {
202
325
  pi.on("tool_call", async (event, ctx) => {
203
326
  const state = getState();
204
327
  const tool = event.toolName;
205
328
  const settings = loadWorkflowSettings(ctx.cwd);
206
329
 
207
- // Sub-agent child processes should obey their own --tools allow-list from the
208
- // agent file. Parent workflow phase guards must not remove bash/read tools.
209
- // Destructive bash remains blocked when global safety requires it.
210
- if (isSubagentWorker()) {
211
- if (tool === "bash") {
212
- const command = String((event.input as { command?: unknown }).command ?? "");
213
- if (commandBlocked(command, ctx.cwd)) return { block: true, reason: `Workflow safety blocked destructive sub-agent bash command: ${command}` };
214
- }
215
- return;
216
- }
217
-
218
- if (repoLockEnabled(settings)) {
330
+ const effectiveRepoLockEnabled = repoLockEnabled(settings) || process.env.PI_WORKFLOW_REPO_LOCK_ENABLED === "1";
331
+ if (effectiveRepoLockEnabled) {
219
332
  if (PATH_SCOPED_TOOLS.has(tool)) {
220
- const reason = repoLockPathBlock((event.input as { path?: unknown }).path, ctx.cwd);
333
+ const input = event.input as { path?: unknown; file_path?: unknown };
334
+ const reason = repoLockPathBlock(input.path ?? input.file_path, ctx.cwd, tool);
221
335
  if (reason) return { block: true, reason };
222
336
  }
223
337
  if (tool === "bash") {
@@ -226,20 +340,31 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
226
340
  if (reason) return { block: true, reason };
227
341
  }
228
342
  if (tool === "subagent") {
229
- const reason = repoLockPathBlock(".", ctx.cwd);
343
+ const reason = repoLockPathBlock(".", ctx.cwd, tool);
230
344
  if (reason) return { block: true, reason };
231
345
  }
232
346
  }
233
347
 
348
+ if (isSubagentWorker()) {
349
+ if (tool === "bash") {
350
+ 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}` };
352
+ }
353
+ return;
354
+ }
355
+
234
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." };
235
357
 
236
- if ((tool === WORKFLOW_PLAN_RESULT_TOOL && state.mode !== "planning") || (tool === MISSION_PLAN_RESULT_TOOL && state.mode !== "mission_planning")) return { block: true, reason: `${tool} is only available during its planning phase.` };
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.` };
237
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." };
238
361
  if (tool === WORKFLOW_EXECUTION_RESULT_TOOL && state.mode !== "executing") return { block: true, reason: "workflow_execution_result is only available during Plan execution." };
239
362
  if (tool === MISSION_MILESTONE_RESULT_TOOL && state.mode !== "mission_running") return { block: true, reason: "mission_milestone_result is only available during Mission execution." };
240
363
  if (tool === WORKFLOW_VALIDATION_RESULT_TOOL && !isValidationResultMode(state.mode)) return { block: true, reason: "workflow_validation_result is only available during validation phases." };
241
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." };
242
365
 
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." };
367
+
243
368
  if (tool === "standard_todo") {
244
369
  if (state.mode !== "standard") return { block: true, reason: "Standard Mode To Do is only available while Standard Mode is active." };
245
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." };
@@ -253,14 +378,18 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
253
378
  }
254
379
  }
255
380
 
381
+ const planProgressBlock = planProgressToolRequiredBlock(state, tool, event.input);
382
+ if (planProgressBlock) return { block: true, reason: planProgressBlock };
383
+
256
384
  if (isPlanMode(state.mode)) {
385
+ if (state.mode === "plan_approved" && state.approvedPlan) return;
257
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)" : ""}` };
258
387
  if (tool === "bash" && settings.safety.disableBashInPlanMode !== false) return { block: true, reason: `Workflow Plan Mode blocks bash. Allowed tools: ${PLAN_TOOLS.join(", ")}` };
259
388
  }
260
389
 
261
390
  if (isValidatorMode(state.mode)) {
262
- if (tool === "edit" || tool === "write") return { block: true, reason: `Workflow Review/Validator Mode blocks ${tool}. Allowed tools: ${VALIDATOR_TOOLS.join(", ")}` };
263
- if (tool === "bash") {
391
+ if (tool === "edit") return { block: true, reason: `Workflow Review/Validator Mode blocks ${tool}. Allowed tools: ${VALIDATOR_TOOLS.join(", ")}` };
392
+ if (tool === "bash" && settings.safety.disableBashInValidatorMode !== false) {
264
393
  const command = String((event.input as { command?: unknown }).command ?? "");
265
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.` };
266
395
  }
@@ -281,7 +410,7 @@ export function registerToolGuard(pi: ExtensionAPI, getState: () => WorkflowStat
281
410
  return;
282
411
  }
283
412
 
284
- if (repoLockEnabled(settings)) {
413
+ if (repoLockEnabled(settings) || process.env.PI_WORKFLOW_REPO_LOCK_ENABLED === "1") {
285
414
  const reason = repoLockBashBlock(event.command, ctx.cwd);
286
415
  if (reason) return { result: { output: reason, exitCode: 1, cancelled: false, truncated: false } };
287
416
  }
@@ -40,10 +40,12 @@ export function validationReportHasRepairableIssue(text?: string): boolean {
40
40
  if (!normalized.trim()) return false;
41
41
  const actionable = normalized
42
42
  .replace(/\bno (actual |concrete )?(code |repairable )?(failure|failures|issue|issues|defect|defects)\b/g, " ")
43
+ .replace(/\bno (blocking|remaining|required) (issue|issues|action|actions|fix|fixes|gap|gaps)\b/g, " ")
44
+ .replace(/\brequired action (?:is )?(?:manual|visual|browser) (?:verification|qa|inspection|confirmation)\b/g, " ")
43
45
  .replace(/\bno automated repair is needed\b/g, " ")
44
46
  .replace(/\bno specific missing requirements? (?:is |are )?identified\b/g, " ")
45
47
  .replace(/\bmanual[-\s]only\b/g, " ");
46
- return /\b(needs? repair|needs? revision|repair pass|repairable (issue|failure|defect)|concrete (issue|failure|defect|regression)|critical issues?|must fix|required fixes|fixes required|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|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);
47
49
  }
48
50
 
49
51
  export function validationReportIsEvidenceGap(text?: string): boolean {
@@ -94,7 +96,8 @@ export function normalizeValidationVerdict(verdict: WorkflowState["validationVer
94
96
  // Re-export the verdict-to-status helper so consumers do not need workflow-parsers.
95
97
  export function planValidationStatusForVerdict(verdict: WorkflowState["validationVerdict"]): PlanValidationStatus {
96
98
  if (verdict === "PASS") return "pass";
97
- if (verdict === "UNKNOWN" || verdict === "PARTIAL PASS") return "unknown";
99
+ if (verdict === "PARTIAL PASS") return "partial pass";
100
+ if (verdict === "UNKNOWN") return "unknown";
98
101
  return "fail";
99
102
  }
100
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediadatafusion/pi-workflow-suite",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Structured workflow orchestration suite for Pi with Standard, Plan, Mission, compaction, diagrams, web access, repo lock, and safety gates.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -36,6 +36,7 @@
36
36
  "type": "module",
37
37
  "files": [
38
38
  "extensions/",
39
+ "!extensions/*.bak",
39
40
  "skills/",
40
41
  "agents/",
41
42
  "config/",
@@ -71,7 +72,8 @@
71
72
  "./skills"
72
73
  ],
73
74
  "prompts": [
74
- "./config/prompts"
75
+ "./config/prompts",
76
+ "!*.md"
75
77
  ],
76
78
  "themes": [
77
79
  "./themes"
@@ -104,7 +106,7 @@
104
106
  "scripts": {
105
107
  "check:ts": "tsc --noEmit --noCheck",
106
108
  "typecheck": "tsc --noEmit",
107
- "validate": "npm run check:ts && ./scripts/check-clean-release-tree.sh && npm run check:package-size && git diff --check",
109
+ "validate": "npm run check:ts && ./scripts/test-workflow-forced-subagent-regression.sh && ./scripts/test-agent-skill-boundary-regression.sh && ./scripts/test-startup-visual-mode-entry-regression.sh && ./scripts/test-settings-health-regression.sh && ./scripts/test-handoff-visibility-regression.sh && ./scripts/test-mission-milestone-handoff-regression.sh && ./scripts/test-plan-handoff-chain-regression.sh && ./scripts/test-plan-step-progress-regression.sh && ./scripts/test-standard-mode-regression.sh && ./scripts/test-final-handoff-summary-regression.sh && ./scripts/test-clarification-answer-handoff-regression.sh && ./scripts/test-validation-evidence-contract-regression.sh && ./scripts/test-mermaid-guidance-regression.sh && ./scripts/test-runtime-web-tools-regression.sh && ./scripts/test-repolock-scope-regression.sh && ./scripts/test-repo-lock-version-regression.sh && ./scripts/test-package-menu-surface.sh && npm run check:package-size && git diff --check",
108
110
  "check:package-size": "node scripts/check-package-size.mjs",
109
111
  "prepack": "node scripts/prepare-package-readme.mjs apply",
110
112
  "postpack": "node scripts/prepare-package-readme.mjs restore --pack",
@@ -14,7 +14,7 @@ printf 'A live backup will be created before installing files.\n'
14
14
  is_forbidden_path() {
15
15
  local rel="$1"
16
16
  case "$rel" in
17
- auth.json|settings.json|workflow-settings.json|active.json|workflows/*|missions/*|plans/*|sessions/*|logs/*|*.log|*.backup.*|*.broken.*|.env|.env.*|.factory/*|.cursor/*|*.DS_Store|*.tmp)
17
+ auth.json|settings.json|workflow-settings.json|active.json|workflows/*|missions/*|plans/*|sessions/*|logs/*|*.log|*.backup.*|*.broken.*|.env|.env.*|.factory/*|.cursor/*|.kilo/*|node_modules/*|*.DS_Store|*.tmp)
18
18
  return 0
19
19
  ;;
20
20
  esac
@@ -83,5 +83,6 @@ install_dir "extensions"
83
83
  install_dir "agents"
84
84
  install_dir "skills"
85
85
  install_dir "config"
86
+ install_dir "themes"
86
87
 
87
88
  printf 'install complete; auth, settings, and workflow state were not touched\n'