pi-crew 0.5.5 → 0.5.7

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 (74) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +17 -1
  3. package/docs/architecture.md +2 -0
  4. package/docs/migration-v0.4-v0.5.md +19 -2
  5. package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
  6. package/package.json +7 -5
  7. package/src/benchmark/benchmark-runner.ts +45 -0
  8. package/src/benchmark/feedback-loop.ts +5 -0
  9. package/src/config/config.ts +38 -4
  10. package/src/config/defaults.ts +5 -0
  11. package/src/config/suggestions.ts +8 -0
  12. package/src/extension/async-notifier.ts +10 -1
  13. package/src/extension/cross-extension-rpc.ts +1 -1
  14. package/src/extension/notification-router.ts +18 -0
  15. package/src/extension/register.ts +13 -17
  16. package/src/extension/registration/subagent-tools.ts +1 -1
  17. package/src/extension/team-tool/anchor.ts +201 -0
  18. package/src/extension/team-tool/api.ts +2 -1
  19. package/src/extension/team-tool/auto-summarize.ts +154 -0
  20. package/src/extension/team-tool/run.ts +37 -2
  21. package/src/extension/team-tool.ts +44 -2
  22. package/src/hooks/registry.ts +1 -3
  23. package/src/observability/event-bus.ts +13 -4
  24. package/src/observability/event-to-metric.ts +0 -2
  25. package/src/runtime/anchor-manager.ts +473 -0
  26. package/src/runtime/async-runner.ts +8 -4
  27. package/src/runtime/auto-summarize.ts +350 -0
  28. package/src/runtime/background-runner.ts +2 -1
  29. package/src/runtime/budget-tracker.ts +354 -0
  30. package/src/runtime/chain-runner.ts +507 -0
  31. package/src/runtime/child-pi.ts +24 -6
  32. package/src/runtime/crash-recovery.ts +5 -4
  33. package/src/runtime/crew-agent-records.ts +32 -1
  34. package/src/runtime/custom-tools/irc-tool.ts +13 -0
  35. package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
  36. package/src/runtime/delivery-coordinator.ts +10 -3
  37. package/src/runtime/dynamic-script-runner.ts +482 -0
  38. package/src/runtime/handoff-manager.ts +589 -0
  39. package/src/runtime/hidden-handoff.ts +424 -0
  40. package/src/runtime/live-agent-manager.ts +20 -4
  41. package/src/runtime/live-session-runtime.ts +39 -4
  42. package/src/runtime/manifest-cache.ts +2 -1
  43. package/src/runtime/model-resolver.ts +16 -4
  44. package/src/runtime/phase-tracker.ts +373 -0
  45. package/src/runtime/pipeline-runner.ts +514 -0
  46. package/src/runtime/retry-runner.ts +354 -0
  47. package/src/runtime/sandbox.ts +252 -0
  48. package/src/runtime/scheduler.ts +7 -2
  49. package/src/runtime/subagent-manager.ts +1 -1
  50. package/src/runtime/task-graph.ts +11 -1
  51. package/src/runtime/task-runner.ts +15 -1
  52. package/src/runtime/team-runner.ts +4 -3
  53. package/src/schema/team-tool-schema.ts +31 -0
  54. package/src/skills/discover-skills.ts +5 -0
  55. package/src/state/active-run-registry.ts +19 -3
  56. package/src/state/contracts.ts +9 -0
  57. package/src/state/crew-init.ts +3 -3
  58. package/src/state/decision-ledger.ts +26 -32
  59. package/src/state/event-log-rotation.ts +2 -2
  60. package/src/state/event-log.ts +17 -4
  61. package/src/state/mailbox.ts +35 -1
  62. package/src/state/run-cache.ts +18 -8
  63. package/src/tools/safe-bash-extension.ts +1 -0
  64. package/src/tools/safe-bash.ts +153 -20
  65. package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
  66. package/src/ui/powerbar-publisher.ts +1 -0
  67. package/src/ui/transcript-cache.ts +13 -0
  68. package/src/utils/bm25-search.ts +16 -8
  69. package/src/utils/env-filter.ts +8 -5
  70. package/src/utils/redaction.ts +169 -15
  71. package/src/utils/sse-parser.ts +10 -1
  72. package/src/worktree/cleanup.ts +6 -1
  73. package/workflows/chain.workflow.md +252 -0
  74. package/workflows/pipeline.workflow.md +27 -0
@@ -0,0 +1,514 @@
1
+ import type { TeamTaskState } from "../state/types.ts";
2
+ import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
3
+ import type { TeamConfig } from "../teams/team-config.ts";
4
+ import type { AgentConfig } from "../agents/agent-config.ts";
5
+ import { writeArtifact } from "../state/artifact-store.ts";
6
+ import { appendEvent, appendEventAsync } from "../state/event-log.ts";
7
+ import { mapConcurrent } from "./parallel-utils.ts";
8
+
9
+ /**
10
+ * Pipeline stage configuration.
11
+ */
12
+ export interface PipelineStage {
13
+ name: string;
14
+ team: string;
15
+ inputs: unknown;
16
+ /** Enable fan-out when inputs is an array (default: true) */
17
+ fanOut?: boolean;
18
+ /** Maximum concurrent executions for fan-out (default: 5) */
19
+ maxConcurrency?: number;
20
+ /** Stop pipeline if this stage fails (default: true) */
21
+ stopOnError?: boolean;
22
+ /** Pass previous stage results as inputs (default: true) */
23
+ usePreviousResults?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Pipeline workflow configuration.
28
+ */
29
+ export interface PipelineWorkflow {
30
+ name: string;
31
+ description: string;
32
+ goal: string;
33
+ stages: PipelineStage[];
34
+ /** Stop pipeline if any stage fails (default: true) */
35
+ stopOnError?: boolean;
36
+ /** Default max concurrency for fan-out stages */
37
+ defaultMaxConcurrency?: number;
38
+ /** Context passed to all stages */
39
+ context?: Record<string, unknown>;
40
+ }
41
+
42
+ /**
43
+ * Context passed to each stage execution.
44
+ */
45
+ export interface PipelineContext {
46
+ stageIndex: number;
47
+ stageName: string;
48
+ previousResults: unknown[];
49
+ totalStages: number;
50
+ runId: string;
51
+ }
52
+
53
+ /**
54
+ * Result of a single stage execution.
55
+ */
56
+ export interface StageResult {
57
+ name: string;
58
+ status: "completed" | "failed" | "skipped";
59
+ results: unknown[];
60
+ error?: string;
61
+ duration: number;
62
+ fanOutItems?: number;
63
+ }
64
+
65
+ /**
66
+ * Complete pipeline execution result.
67
+ */
68
+ export interface PipelineResult {
69
+ stages: StageResult[];
70
+ totalDuration: number;
71
+ finalResults: unknown[];
72
+ status: "completed" | "failed" | "partial";
73
+ }
74
+
75
+ /**
76
+ * PipelineRunner executes multi-stage workflows with automatic fan-out
77
+ * for array inputs.
78
+ */
79
+ export class PipelineRunner {
80
+ private stopOnError: boolean;
81
+ private defaultMaxConcurrency: number;
82
+
83
+ constructor(options?: { stopOnError?: boolean; defaultMaxConcurrency?: number }) {
84
+ this.stopOnError = options?.stopOnError ?? true;
85
+ this.defaultMaxConcurrency = options?.defaultMaxConcurrency ?? 5;
86
+ }
87
+
88
+ /**
89
+ * Execute a pipeline workflow.
90
+ * @param workflow - The pipeline workflow definition
91
+ * @param context - Additional context for execution
92
+ * @param executeStage - Function to execute a single stage
93
+ * @param runId - Run identifier for event logging
94
+ * @param eventsPath - Path to event log file
95
+ */
96
+ async run(
97
+ workflow: PipelineWorkflow,
98
+ context: Record<string, unknown>,
99
+ executeStage: (stage: PipelineStage, inputs: unknown, stageContext: PipelineContext) => Promise<unknown>,
100
+ runId: string,
101
+ eventsPath: string,
102
+ ): Promise<PipelineResult> {
103
+ const stages: StageResult[] = [];
104
+ let previousResults: unknown[] = [];
105
+ const startTime = Date.now();
106
+
107
+ await appendEventAsync(eventsPath, {
108
+ type: "pipeline:started",
109
+ runId,
110
+ message: `Pipeline '${workflow.name}' started`,
111
+ data: { stages: workflow.stages.map((s) => s.name) },
112
+ });
113
+
114
+ for (let i = 0; i < workflow.stages.length; i++) {
115
+ const stage = workflow.stages[i];
116
+ const stageStartTime = Date.now();
117
+
118
+ // Determine stop behavior for this stage
119
+ const effectiveStopOnError = stage.stopOnError ?? workflow.stopOnError ?? this.stopOnError;
120
+
121
+ await appendEventAsync(eventsPath, {
122
+ type: "pipeline:stage_started",
123
+ runId,
124
+ message: `Stage '${stage.name}' started`,
125
+ data: { stageIndex: i, stageName: stage.name },
126
+ });
127
+
128
+ try {
129
+ // Build stage context
130
+ const stageContext: PipelineContext = {
131
+ stageIndex: i,
132
+ stageName: stage.name,
133
+ previousResults,
134
+ totalStages: workflow.stages.length,
135
+ runId,
136
+ };
137
+
138
+ // Resolve inputs
139
+ const inputs = this.resolveInputs(stage.inputs, previousResults, context);
140
+
141
+ // Execute stage (handle fan-out if enabled)
142
+ const results = await this.executeStageInternal(
143
+ stage,
144
+ inputs,
145
+ stageContext,
146
+ executeStage,
147
+ );
148
+
149
+ const duration = Date.now() - stageStartTime;
150
+ stages.push({
151
+ name: stage.name,
152
+ status: "completed",
153
+ results,
154
+ duration,
155
+ fanOutItems: Array.isArray(inputs) ? inputs.length : undefined,
156
+ });
157
+
158
+ previousResults = results;
159
+
160
+ await appendEventAsync(eventsPath, {
161
+ type: "pipeline:stage_completed",
162
+ runId,
163
+ message: `Stage '${stage.name}' completed`,
164
+ data: { stageIndex: i, stageName: stage.name, duration, resultCount: results.length },
165
+ });
166
+ } catch (error) {
167
+ const duration = Date.now() - stageStartTime;
168
+ const errorMessage = error instanceof Error ? error.message : String(error);
169
+
170
+ if (effectiveStopOnError) {
171
+ stages.push({
172
+ name: stage.name,
173
+ status: "failed",
174
+ results: [],
175
+ error: errorMessage,
176
+ duration,
177
+ });
178
+
179
+ await appendEventAsync(eventsPath, {
180
+ type: "pipeline:stage_failed",
181
+ runId,
182
+ message: `Stage '${stage.name}' failed: ${errorMessage}`,
183
+ data: { stageIndex: i, stageName: stage.name, duration, error: errorMessage },
184
+ });
185
+
186
+ await appendEventAsync(eventsPath, {
187
+ type: "pipeline:failed",
188
+ runId,
189
+ message: `Pipeline '${workflow.name}' failed at stage '${stage.name}'`,
190
+ data: { failedStage: stage.name, error: errorMessage },
191
+ });
192
+
193
+ return {
194
+ stages,
195
+ totalDuration: Date.now() - startTime,
196
+ finalResults: previousResults,
197
+ status: "failed",
198
+ };
199
+ } else {
200
+ stages.push({
201
+ name: stage.name,
202
+ status: "failed",
203
+ results: [],
204
+ error: errorMessage,
205
+ duration,
206
+ });
207
+
208
+ await appendEventAsync(eventsPath, {
209
+ type: "pipeline:stage_skipped",
210
+ runId,
211
+ message: `Stage '${stage.name}' skipped due to error`,
212
+ data: { stageIndex: i, stageName: stage.name, duration, error: errorMessage },
213
+ });
214
+ }
215
+ }
216
+ }
217
+
218
+ await appendEventAsync(eventsPath, {
219
+ type: "pipeline:completed",
220
+ runId,
221
+ message: `Pipeline '${workflow.name}' completed`,
222
+ data: { stages: stages.map((s) => ({ name: s.name, status: s.status })) },
223
+ });
224
+
225
+ return {
226
+ stages,
227
+ totalDuration: Date.now() - startTime,
228
+ finalResults: previousResults,
229
+ status: stages.some((s) => s.status === "failed") ? "partial" : "completed",
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Execute a single stage, handling fan-out for array inputs.
235
+ * Uses depth parameter to prevent stack overflow from deep recursion.
236
+ */
237
+ private async executeStageInternal(
238
+ stage: PipelineStage,
239
+ inputs: unknown,
240
+ stageContext: PipelineContext,
241
+ callback: (stage: PipelineStage, inputs: unknown, stageContext: PipelineContext) => Promise<unknown>,
242
+ depth: number = 0,
243
+ ): Promise<unknown[]> {
244
+ // CRITICAL-6: Prevent stack overflow from deep recursion
245
+ if (depth > 50) {
246
+ throw new Error(`Pipeline recursion depth limit exceeded (${depth}). Possible circular stage dependency.`);
247
+ }
248
+
249
+ const fanOut = stage.fanOut ?? true;
250
+ const maxConcurrency = stage.maxConcurrency ?? this.defaultMaxConcurrency;
251
+
252
+ // Fan-out if inputs is an array with multiple items to process.
253
+ // We don't fan-out for single-element arrays as they typically represent
254
+ // the result of a previous stage that returned a single value.
255
+ const shouldFanOut = fanOut && Array.isArray(inputs) && inputs.length > 1;
256
+
257
+ // Increment depth for non-fan-out path
258
+ const nextDepth = depth + 1;
259
+
260
+ if (shouldFanOut) {
261
+ const tasks = (inputs as unknown[]).map((item, index) => ({
262
+ item,
263
+ index,
264
+ name: `${stage.name}[${index}]`,
265
+ }));
266
+
267
+ // Execute with concurrency limit - pass each item to callback
268
+ const results = await mapConcurrent(
269
+ tasks,
270
+ maxConcurrency,
271
+ async (task) => {
272
+ // Call the user-provided callback with each item
273
+ const result = await this.executeStageInternal(stage, task.item, {
274
+ ...stageContext,
275
+ stageName: task.name,
276
+ }, callback, nextDepth);
277
+ return result;
278
+ },
279
+ );
280
+
281
+ return results;
282
+ }
283
+
284
+ // Single execution - pass inputs directly to callback
285
+ const result = await callback(stage, inputs, stageContext);
286
+ return [result];
287
+ }
288
+
289
+ /**
290
+ * Resolve inputs from template strings and previous results.
291
+ * Supports JMESPath-like resolution:
292
+ * - ${previous} -> previousResults
293
+ * - ${previous[0]} -> previousResults[0]
294
+ * - ${context.key} -> context.key
295
+ * - ${args.x} -> context.args.x
296
+ *
297
+ * C5: Validates template inputs to prevent injection.
298
+ */
299
+ private resolveInputs(
300
+ inputs: unknown,
301
+ previousResults: unknown[],
302
+ context: Record<string, unknown>,
303
+ ): unknown {
304
+ // If inputs is an array, resolve each element
305
+ if (Array.isArray(inputs)) {
306
+ // H4: Type safety - limit array size to prevent memory issues
307
+ const maxItems = 10000;
308
+ const limitedInputs = inputs.length > maxItems ? inputs.slice(0, maxItems) : inputs;
309
+ return limitedInputs.map((input) => this.resolveInputs(input, previousResults, context));
310
+ }
311
+
312
+ // If inputs is a string, check for template patterns
313
+ if (typeof inputs === "string") {
314
+ return this.resolveTemplate(inputs, previousResults, context);
315
+ }
316
+
317
+ // If inputs is an object, resolve each value
318
+ if (typeof inputs === "object" && inputs !== null) {
319
+ const resolved: Record<string, unknown> = {};
320
+ for (const [key, value] of Object.entries(inputs)) {
321
+ // C5: Validate key to prevent prototype pollution
322
+ if (this.isValidObjectKey(key)) {
323
+ resolved[key] = this.resolveInputs(value as string | string[] | Record<string, unknown>, previousResults, context);
324
+ }
325
+ }
326
+ return resolved;
327
+ }
328
+
329
+ // Primitive value - return as-is
330
+ return inputs;
331
+ }
332
+
333
+ /**
334
+ * C5: Validate object key to prevent prototype pollution and injection.
335
+ */
336
+ private isValidObjectKey(key: string): boolean {
337
+ // Reject dangerous keys
338
+ const dangerousKeys = [
339
+ '__proto__',
340
+ 'constructor',
341
+ 'prototype',
342
+ '__defineGetter__',
343
+ '__defineSetter__',
344
+ '__lookupGetter__',
345
+ '__lookupSetter__',
346
+ ];
347
+ if (dangerousKeys.includes(key)) {
348
+ return false;
349
+ }
350
+ // Reject keys with null bytes or control characters
351
+ if (/[\x00-\x1F\x7F]/.test(key)) {
352
+ return false;
353
+ }
354
+ // Reject overly long keys
355
+ if (key.length > 256) {
356
+ return false;
357
+ }
358
+ return true;
359
+ }
360
+
361
+ /**
362
+ * C5: Validate nested path (e.g., "nested.deep.value") to prevent injection.
363
+ * Each part is validated individually.
364
+ */
365
+ private isValidNestedPath(path: string): boolean {
366
+ // Reject empty paths
367
+ if (!path || path.length === 0) {
368
+ return false;
369
+ }
370
+ // Reject overly long paths
371
+ if (path.length > 512) {
372
+ return false;
373
+ }
374
+ // Reject paths with empty segments
375
+ if (path.includes('..')) {
376
+ return false;
377
+ }
378
+ // Validate each path segment
379
+ const parts = path.split('.');
380
+ for (const part of parts) {
381
+ if (!this.isValidObjectKey(part)) {
382
+ return false;
383
+ }
384
+ }
385
+ return true;
386
+ }
387
+
388
+ /**
389
+ * Resolve a single template string.
390
+ * C5: Validates template inputs to prevent injection.
391
+ */
392
+ private resolveTemplate(
393
+ template: string,
394
+ previousResults: unknown[],
395
+ context: Record<string, unknown>,
396
+ ): unknown {
397
+ // C5: Validate template length to prevent DoS
398
+ if (template.length > 10000) {
399
+ return template;
400
+ }
401
+
402
+ // Check for ${previous} pattern
403
+ const previousMatch = template.match(/^\$\{previous\}$/);
404
+ if (previousMatch) {
405
+ return previousResults;
406
+ }
407
+
408
+ // Check for ${previous[N]} pattern with bounds checking
409
+ const previousIndexMatch = template.match(/^\$\{previous\[(\d+)\]\}$/);
410
+ if (previousIndexMatch) {
411
+ const index = parseInt(previousIndexMatch[1], 10);
412
+ // H4: Type safety - validate index bounds
413
+ if (index >= 0 && index < previousResults.length) {
414
+ return previousResults[index];
415
+ }
416
+ return undefined;
417
+ }
418
+
419
+ // Check for ${context.key} pattern with sanitized key extraction
420
+ const contextMatch = template.match(/^\$\{context\.([a-zA-Z_][a-zA-Z0-9_.]*)\}$/);
421
+ if (contextMatch) {
422
+ const key = contextMatch[1];
423
+ // C5: Validate the full path (each part validated in getNestedValue)
424
+ if (this.isValidNestedPath(key)) {
425
+ return this.getNestedValue(context, key);
426
+ }
427
+ return undefined;
428
+ }
429
+
430
+ // Check for ${args.key} pattern with sanitized key extraction
431
+ const argsMatch = template.match(/^\$\{args\.([a-zA-Z_][a-zA-Z0-9_.]*)\}$/);
432
+ if (argsMatch) {
433
+ const key = argsMatch[1];
434
+ // C5: Validate the full path (each part validated in getNestedValue)
435
+ if (this.isValidNestedPath(key)) {
436
+ const args = (context.args as Record<string, unknown>) ?? {};
437
+ return this.getNestedValue(args, key);
438
+ }
439
+ return undefined;
440
+ }
441
+
442
+ // No pattern matched - return template as-is
443
+ return template;
444
+ }
445
+
446
+ /**
447
+ * Get nested value from object using dot notation.
448
+ * H4: Type safety - validates path and prevents prototype pollution.
449
+ */
450
+ private getNestedValue(obj: Record<string, unknown>, path: string): unknown {
451
+ const parts = path.split(".");
452
+ let current: unknown = obj;
453
+
454
+ for (const part of parts) {
455
+ // H4: Validate each path part
456
+ if (!this.isValidObjectKey(part)) {
457
+ return undefined;
458
+ }
459
+ if (current === null || current === undefined) {
460
+ return undefined;
461
+ }
462
+ if (typeof current !== "object") {
463
+ return undefined;
464
+ }
465
+ current = (current as Record<string, unknown>)[part];
466
+ }
467
+
468
+ return current;
469
+ }
470
+
471
+ /**
472
+ * Parse a pipeline workflow from a workflow configuration.
473
+ * Converts standard WorkflowConfig to PipelineWorkflow.
474
+ */
475
+ static fromWorkflowConfig(
476
+ workflow: WorkflowConfig,
477
+ goal: string,
478
+ ): PipelineWorkflow {
479
+ const stages: PipelineStage[] = workflow.steps.map((step) => ({
480
+ name: step.id,
481
+ team: step.role, // Using role as team identifier
482
+ inputs: step.task,
483
+ usePreviousResults: step.dependsOn && step.dependsOn.length > 0,
484
+ }));
485
+
486
+ return {
487
+ name: workflow.name,
488
+ description: workflow.description,
489
+ goal,
490
+ stages,
491
+ stopOnError: true,
492
+ defaultMaxConcurrency: workflow.maxConcurrency ?? 5,
493
+ };
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Create a pipeline workflow from a goal and stage definitions.
499
+ */
500
+ export function createPipelineWorkflow(
501
+ name: string,
502
+ description: string,
503
+ goal: string,
504
+ stages: PipelineStage[],
505
+ ): PipelineWorkflow {
506
+ return {
507
+ name,
508
+ description,
509
+ goal,
510
+ stages,
511
+ stopOnError: true,
512
+ defaultMaxConcurrency: 5,
513
+ };
514
+ }