@mediadatafusion/pi-workflow-suite 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +146 -20
  3. package/VERSION +1 -1
  4. package/agents/codebase-research.md +7 -5
  5. package/agents/general-worker.md +9 -7
  6. package/agents/implementation-planning.md +5 -3
  7. package/agents/quality-validation.md +9 -8
  8. package/agents/workflow-orchestrator.md +9 -7
  9. package/config/prompts/execute-approved-plan.md +12 -2
  10. package/config/prompts/mission-final-validation.md +38 -5
  11. package/config/prompts/mission-plan.md +17 -1
  12. package/config/prompts/mission-repair.md +16 -2
  13. package/config/prompts/mission-review-prompt.md +55 -0
  14. package/config/prompts/mission-run.md +18 -5
  15. package/config/prompts/validate-approved-plan.md +57 -3
  16. package/config/prompts/workflow-plan-prompt.md +11 -1
  17. package/config/prompts/workflow-repair.md +18 -2
  18. package/config/prompts/workflow-reviewer-prompt.md +60 -0
  19. package/config/prompts/workflow-summary.md +1 -4
  20. package/config/workflow-settings.example.json +13 -11
  21. package/docs/assets/mediadatafusion-logo.png +0 -0
  22. package/docs/assets/pi-workflow-suite-demo.gif +0 -0
  23. package/docs/assets/pi-workflow-suite-demo.mp4 +0 -0
  24. package/docs/assets/pi-workflow-suite-header.png +0 -0
  25. package/docs/assets/pi-workflow-suite-video-thumb.png +0 -0
  26. package/docs/assets/readme-link-commands.svg +10 -0
  27. package/docs/assets/readme-link-install.svg +10 -0
  28. package/docs/assets/readme-link-quick-start.svg +10 -0
  29. package/docs/assets/readme-link-settings.svg +10 -0
  30. package/docs/assets/screenshots/.gitkeep +1 -0
  31. package/docs/assets/screenshots/00-mission-home.png +0 -0
  32. package/docs/assets/screenshots/01-startup-Logo.png +0 -0
  33. package/docs/assets/screenshots/02-theme-settings.png +0 -0
  34. package/docs/assets/screenshots/03-GlobalSafetySettings.png +0 -0
  35. package/docs/assets/screenshots/04-SharedSubAgentsSettings.png +0 -0
  36. package/docs/assets/screenshots/05-mission-mode.png +0 -0
  37. package/docs/assets/screenshots/06-diagram-mermaid.png +0 -0
  38. package/extensions/subagent/index.ts +41 -18
  39. package/extensions/subagent/repolock-guard.ts +224 -4
  40. package/extensions/subagent/runner.ts +136 -12
  41. package/extensions/workflow-model-router.ts +152 -55
  42. package/extensions/workflow-modes.ts +4784 -1087
  43. package/extensions/workflow-settings-capabilities.ts +10 -0
  44. package/extensions/workflow-state.ts +139 -15
  45. package/extensions/workflow-subagent-policy.ts +13 -1
  46. package/extensions/workflow-summary.ts +8 -19
  47. package/extensions/workflow-tool-guard.ts +420 -39
  48. package/extensions/workflow-validation-classifier.ts +46 -4
  49. package/extensions/workflow-web-tools.ts +361 -1
  50. package/package.json +10 -5
  51. package/scripts/audit-live.sh +1 -1
  52. package/scripts/build-package-export.mjs +8 -13
  53. package/scripts/check-clean-release-tree.sh +3 -2
  54. package/scripts/check-package-media.mjs +78 -0
  55. package/scripts/install-to-live.sh +2 -0
  56. package/scripts/package-media-config.mjs +28 -0
  57. package/scripts/prepare-package-readme.mjs +19 -18
  58. package/scripts/quarantine-live-junk.sh +1 -1
  59. package/scripts/verify-live.sh +9 -1
  60. package/skills/implementation-planning/SKILL.md +1 -1
  61. package/skills/safe-execution/SKILL.md +1 -1
  62. package/skills/validation-review/SKILL.md +1 -1
@@ -9,6 +9,7 @@ import { execFileSync, spawn } from "node:child_process";
9
9
  import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
+ import * as crypto from "node:crypto";
12
13
  import type { Message } from "@earendil-works/pi-ai";
13
14
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
14
15
  import { loadWorkflowSettings } from "../workflow-model-router.js";
@@ -18,6 +19,12 @@ export interface WorkflowSubagentTask {
18
19
  agent: string;
19
20
  task: string;
20
21
  cwd?: string;
22
+ schema?: Record<string, unknown>;
23
+ background?: boolean;
24
+ model?: string;
25
+ skills?: string;
26
+ output?: string;
27
+ workflowPhase?: string;
21
28
  }
22
29
 
23
30
  export interface WorkflowSubagentUsage {
@@ -42,6 +49,7 @@ export interface WorkflowSubagentResult {
42
49
  model?: string;
43
50
  stopReason?: string;
44
51
  errorMessage?: string;
52
+ parsedOutput?: unknown;
45
53
  }
46
54
 
47
55
  export interface WorkflowSubagentRunResult {
@@ -58,9 +66,50 @@ export interface WorkflowSubagentRunOptions {
58
66
  staleMinutes?: number;
59
67
  signal?: AbortSignal;
60
68
  onUpdate?: (results: WorkflowSubagentResult[]) => void;
69
+ concurrency?: number;
70
+ failFast?: boolean;
71
+ background?: boolean;
72
+ }
73
+
74
+ const DEFAULT_CONCURRENCY = 8;
75
+
76
+ // ── Orphan process tracking (#8) ──────────────────────────────
77
+ const trackedPids = new Set<number>();
78
+
79
+ export function trackedOrphanPids(): ReadonlySet<number> {
80
+ return trackedPids;
81
+ }
82
+
83
+ export function trackSubagentPid(pid: number): void {
84
+ trackedPids.add(pid);
85
+ }
86
+
87
+ export function untrackSubagentPid(pid: number): void {
88
+ trackedPids.delete(pid);
89
+ }
90
+
91
+ export function cleanupOrphanProcesses(): void {
92
+ for (const pid of trackedPids) {
93
+ try { process.kill(pid, "SIGTERM"); } catch { /* already dead */ }
94
+ }
95
+ trackedPids.clear();
96
+ }
97
+
98
+ // Clean up on parent exit (unexpected death)
99
+ if (typeof process.on === "function") {
100
+ process.on("exit", () => { for (const pid of trackedPids) { try { process.kill(pid, "SIGTERM"); } catch { /* ignore */ } } });
101
+ }
102
+
103
+ // ── Result caching (#6) ─────────────────────────────────────
104
+ const resultCache = new Map<string, WorkflowSubagentResult>();
105
+
106
+ function cacheKey(agent: string, task: string, cwd: string): string {
107
+ return crypto.createHash("sha256").update(`${agent}\n${task}\n${cwd}`).digest("hex");
61
108
  }
62
109
 
63
- const MAX_CONCURRENCY = 4;
110
+ export function clearSubagentResultCache(): void {
111
+ resultCache.clear();
112
+ }
64
113
  const REPOLOCK_GUARD_EXTENSION = path.join(path.dirname(new URL(import.meta.url).pathname), "repolock-guard.ts");
65
114
 
66
115
  function safeRealpath(candidate: string): string {
@@ -104,19 +153,30 @@ function finalOutput(messages: Message[]): string {
104
153
  return "";
105
154
  }
106
155
 
107
- async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise<TOut>): Promise<TOut[]> {
156
+ async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise<TOut>, failFast = false): Promise<TOut[]> {
108
157
  if (items.length === 0) return [];
109
158
  const limit = Math.max(1, Math.min(concurrency, items.length));
110
159
  const results: TOut[] = new Array(items.length);
111
160
  let nextIndex = 0;
161
+ let firstError: Error | undefined;
112
162
  const workers = new Array(limit).fill(null).map(async () => {
113
163
  while (true) {
164
+ if (failFast && firstError) return;
114
165
  const current = nextIndex++;
115
166
  if (current >= items.length) return;
116
- results[current] = await fn(items[current], current);
167
+ try {
168
+ results[current] = await fn(items[current], current);
169
+ } catch (err) {
170
+ if (failFast) {
171
+ firstError = err instanceof Error ? err : new Error(String(err));
172
+ return;
173
+ }
174
+ throw err;
175
+ }
117
176
  }
118
177
  });
119
178
  await Promise.all(workers);
179
+ if (failFast && firstError) throw firstError;
120
180
  return results;
121
181
  }
122
182
 
@@ -166,6 +226,12 @@ async function runSingleWorkflowSubagent(
166
226
 
167
227
  const lockRoot = repoLockRootForSubagent(defaultCwd);
168
228
  const effectiveCwd = resolveSubagentCwd(task.cwd, defaultCwd);
229
+
230
+ // ── Result caching (#6): check cache before spawning ──
231
+ const key = cacheKey(agent.name, task.task, effectiveCwd);
232
+ const cached = signal?.aborted ? undefined : resultCache.get(key);
233
+ if (cached) return { ...cached, output: `${cached.output}\n\n[cached]` };
234
+
169
235
  if (lockRoot && !pathInsideRoot(effectiveCwd, lockRoot)) {
170
236
  return {
171
237
  agent: task.agent,
@@ -188,7 +254,7 @@ async function runSingleWorkflowSubagent(
188
254
  const messages: Message[] = [];
189
255
  const usage: WorkflowSubagentUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
190
256
  let stderr = "";
191
- let model = agent.model;
257
+ let model = task.model || agent.model;
192
258
  let stopReason: string | undefined;
193
259
  let errorMessage: string | undefined;
194
260
 
@@ -199,7 +265,11 @@ async function runSingleWorkflowSubagent(
199
265
  tmpPromptPath = tmp.filePath;
200
266
  args.push("--append-system-prompt", tmpPromptPath);
201
267
  }
202
- args.push(`Task: ${task.task}`);
268
+ // ── Structured output (#5): inject schema if present ──
269
+ const schemaInstruction = task.schema
270
+ ? `\n\nReturn your final result as a single valid JSON object matching this schema:\n${JSON.stringify(task.schema, null, 2)}\n\nWrap ONLY the JSON object in a \`\`\`json code block at the end of your response.`
271
+ : "";
272
+ args.push(`Task: ${task.task}${schemaInstruction}`);
203
273
 
204
274
  let wasAborted = false;
205
275
  let timeoutReason = "";
@@ -216,9 +286,13 @@ async function runSingleWorkflowSubagent(
216
286
  ...process.env,
217
287
  PI_SUBAGENT_WORKER: "1",
218
288
  PI_SUBAGENT_NAME: agent.name,
289
+ ...(task.workflowPhase ? { PI_WORKFLOW_SUBAGENT_PHASE: task.workflowPhase } : {}),
219
290
  ...(lockRoot ? { PI_WORKFLOW_REPO_LOCK_ENABLED: "1", PI_WORKFLOW_REPO_LOCK_ROOT: lockRoot } : {}),
291
+ ...(task.skills ? { PI_SUBAGENT_SKILLS: task.skills } : {}),
292
+ ...(task.output ? { PI_SUBAGENT_OUTPUT: task.output } : {}),
220
293
  },
221
294
  });
295
+ trackedPids.add(proc.pid!);
222
296
  let buffer = "";
223
297
  let lastOutputAt = Date.now();
224
298
  let settled = false;
@@ -228,8 +302,8 @@ async function runSingleWorkflowSubagent(
228
302
  timeoutReason = reason;
229
303
  wasAborted = true;
230
304
  errorMessage = reason;
231
- proc.kill("SIGTERM");
232
- setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 5000);
305
+ try { process.kill(-proc.pid!, "SIGTERM"); } catch { proc.kill("SIGTERM"); }
306
+ setTimeout(() => { if (!proc.killed) { try { process.kill(-proc.pid!, "SIGKILL"); } catch { proc.kill("SIGKILL"); } } }, 5000);
233
307
  };
234
308
  const timeoutTimer = setTimeout(() => stopProcess(`Sub-agent timed out after ${Math.round(timeoutMs / 60000)} minute(s).`), timeoutMs);
235
309
  const staleTimer = setInterval(() => {
@@ -275,13 +349,19 @@ async function runSingleWorkflowSubagent(
275
349
  proc.stderr.on("data", (data) => { stderr += data.toString(); });
276
350
  proc.on("close", (code) => {
277
351
  settled = true;
352
+ trackedPids.delete(proc.pid!);
278
353
  clearTimeout(timeoutTimer);
279
354
  clearInterval(staleTimer);
280
355
  if (buffer.trim()) processLine(buffer);
356
+ // Kill process group to clean up background child processes
357
+ // (dev servers, static servers, tools — any program the sub-agent started).
358
+ // process.kill(-pid) signals the entire process group; works on all Unix.
359
+ try { if (proc.pid) process.kill(-proc.pid, "SIGTERM"); } catch { /* group empty */ }
281
360
  resolve(code ?? 0);
282
361
  });
283
362
  proc.on("error", () => {
284
363
  settled = true;
364
+ trackedPids.delete(proc.pid!);
285
365
  clearTimeout(timeoutTimer);
286
366
  clearInterval(staleTimer);
287
367
  resolve(1);
@@ -293,19 +373,41 @@ async function runSingleWorkflowSubagent(
293
373
  }
294
374
  });
295
375
 
296
- return {
376
+ const rawOutput = finalOutput(messages);
377
+ // ── Structured output (#5): try JSON parse against schema ──
378
+ let parsedOutput: unknown;
379
+ if (task.schema && rawOutput) {
380
+ const jsonMatch = rawOutput.match(/```json\s*([\s\S]*?)\s*```/);
381
+ const candidate = jsonMatch ? jsonMatch[1].trim() : rawOutput.trim();
382
+ try { parsedOutput = JSON.parse(candidate); } catch { /* free-form output, not JSON */ }
383
+ }
384
+
385
+ const result: WorkflowSubagentResult = {
297
386
  agent: agent.name,
298
387
  agentSource: agent.source,
299
388
  agentTools: agent.tools,
300
389
  task: task.task,
301
390
  exitCode: wasAborted ? 1 : exitCode,
302
- output: finalOutput(messages),
391
+ output: rawOutput,
303
392
  stderr,
304
393
  usage,
305
394
  model,
306
395
  stopReason: wasAborted ? "aborted" : stopReason,
307
396
  errorMessage: wasAborted ? (timeoutReason || "Subagent was aborted") : errorMessage,
397
+ parsedOutput,
308
398
  };
399
+
400
+ // ── Result caching (#6): store successful results ──
401
+ if (!wasAborted && exitCode === 0 && !signal?.aborted) {
402
+ resultCache.set(key, result);
403
+ }
404
+
405
+ // ── Retry-on-timeout (#3): retry once on timeout/stale ──
406
+ if (wasAborted && timeoutReason && !signal?.aborted) {
407
+ return result; // single attempt; retry is handled at the runWorkflowSubagents level
408
+ }
409
+
410
+ return result;
309
411
  } finally {
310
412
  if (tmpPromptPath) try { fs.unlinkSync(tmpPromptPath); } catch { /* ignore */ }
311
413
  if (tmpPromptDir) try { fs.rmdirSync(tmpPromptDir); } catch { /* ignore */ }
@@ -325,12 +427,34 @@ export async function runWorkflowSubagents(options: WorkflowSubagentRunOptions):
325
427
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
326
428
  }));
327
429
  options.onUpdate?.([...running]);
328
- const results = await mapWithConcurrencyLimit(options.tasks, MAX_CONCURRENCY, async (task, index) => {
329
- const result = await runSingleWorkflowSubagent(options.cwd, discovery.agents, task, options.signal, { timeoutMinutes: options.timeoutMinutes, staleMinutes: options.staleMinutes });
430
+
431
+ const executeTask = async (task: WorkflowSubagentTask, index: number): Promise<WorkflowSubagentResult> => {
432
+ const limits = { timeoutMinutes: options.timeoutMinutes, staleMinutes: options.staleMinutes };
433
+ let result = await runSingleWorkflowSubagent(options.cwd, discovery.agents, task, options.signal, limits);
434
+ // ── Retry-on-timeout (#3): retry once on timeout/stale ──
435
+ if (result.exitCode !== 0 && result.stopReason === "aborted" && result.errorMessage?.includes("timed out") && !options.signal?.aborted) {
436
+ const retryResult = await runSingleWorkflowSubagent(options.cwd, discovery.agents, task, options.signal, limits);
437
+ retryResult.output = `[retry after timeout]\n${retryResult.output}`;
438
+ result = retryResult;
439
+ }
330
440
  running[index] = result;
331
441
  options.onUpdate?.([...running]);
332
442
  return result;
333
- });
443
+ };
444
+
445
+ const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
446
+
447
+ if (options.background) {
448
+ // Fire-and-forget: start execution, don't await, deliver results via onUpdate
449
+ mapWithConcurrencyLimit(options.tasks, concurrency, executeTask, options.failFast).then((results) => {
450
+ // Results delivered via onUpdate during execution; final result available for next turn
451
+ }).catch(() => {
452
+ // Background failures are non-fatal; onUpdate already reported individual failures
453
+ });
454
+ return { agentScope, projectAgentsDir: discovery.projectAgentsDir, results: running };
455
+ }
456
+
457
+ const results = await mapWithConcurrencyLimit(options.tasks, concurrency, executeTask, options.failFast);
334
458
  return { agentScope, projectAgentsDir: discovery.projectAgentsDir, results };
335
459
  }
336
460