pi-crew 0.5.25 → 0.6.1

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 (81) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +13 -11
  3. package/docs/patterns/command-agent-skill.md +71 -0
  4. package/package.json +1 -1
  5. package/skills/council/SKILL.md +163 -0
  6. package/src/agents/agent-config.ts +4 -1
  7. package/src/agents/discover-agents.ts +1 -0
  8. package/src/benchmark/feedback-loop.ts +4 -2
  9. package/src/extension/cross-extension-rpc.ts +48 -0
  10. package/src/extension/registration/commands.ts +2 -1
  11. package/src/extension/registration/subagent-tools.ts +2 -0
  12. package/src/extension/registration/team-tool.ts +2 -0
  13. package/src/extension/registration/viewers.ts +1 -0
  14. package/src/extension/run-export.ts +16 -1
  15. package/src/extension/run-import.ts +16 -0
  16. package/src/extension/team-tool/anchor.ts +5 -1
  17. package/src/extension/team-tool/api.ts +9 -4
  18. package/src/extension/team-tool/config-patch.ts +15 -1
  19. package/src/extension/team-tool.ts +2 -1
  20. package/src/hooks/registry.ts +9 -1
  21. package/src/hooks/types.ts +14 -0
  22. package/src/i18n.ts +15 -2
  23. package/src/observability/exporters/otlp-exporter.ts +73 -0
  24. package/src/runtime/adaptive-plan.ts +24 -0
  25. package/src/runtime/agent-control.ts +6 -3
  26. package/src/runtime/async-runner.ts +58 -3
  27. package/src/runtime/background-runner.ts +1 -1
  28. package/src/runtime/chain-parser.ts +192 -0
  29. package/src/runtime/chain-runner.ts +58 -0
  30. package/src/runtime/child-pi.ts +1 -1
  31. package/src/runtime/crew-agent-records.ts +4 -3
  32. package/src/runtime/cross-extension-rpc.ts +34 -8
  33. package/src/runtime/diagnostic-export.ts +3 -4
  34. package/src/runtime/dynamic-script-runner.ts +7 -7
  35. package/src/runtime/foreground-watchdog.ts +2 -2
  36. package/src/runtime/intercom-bridge.ts +178 -0
  37. package/src/runtime/live-agent-manager.ts +6 -3
  38. package/src/runtime/live-irc.ts +4 -2
  39. package/src/runtime/parallel-utils.ts +2 -1
  40. package/src/runtime/plan-templates.ts +200 -0
  41. package/src/runtime/post-checks.ts +10 -3
  42. package/src/runtime/run-drift.ts +220 -0
  43. package/src/runtime/sandbox.ts +26 -20
  44. package/src/runtime/semaphore.ts +2 -1
  45. package/src/runtime/settings-store.ts +14 -2
  46. package/src/runtime/skill-effectiveness.ts +4 -2
  47. package/src/runtime/skill-instructions.ts +4 -1
  48. package/src/runtime/subagent-manager.ts +20 -2
  49. package/src/runtime/subprocess-tool-registry.ts +2 -2
  50. package/src/runtime/task-graph.ts +79 -0
  51. package/src/runtime/task-id.ts +148 -0
  52. package/src/runtime/task-packet.ts +13 -1
  53. package/src/runtime/task-runner/context-retrieval.ts +172 -0
  54. package/src/runtime/task-runner.ts +39 -1
  55. package/src/runtime/team-runner.ts +7 -0
  56. package/src/runtime/usage-tracker.ts +4 -2
  57. package/src/runtime/verification-gates.ts +36 -9
  58. package/src/state/contracts.ts +2 -1
  59. package/src/state/event-log.ts +16 -5
  60. package/src/state/hook-instinct-bridge.ts +2 -1
  61. package/src/state/locks.ts +9 -2
  62. package/src/state/memory-store.ts +244 -0
  63. package/src/state/observation-store.ts +177 -0
  64. package/src/state/state-store.ts +4 -2
  65. package/src/state/task-claims.ts +9 -2
  66. package/src/tools/safe-bash.ts +69 -20
  67. package/src/types/new-api-types.ts +10 -5
  68. package/src/ui/keybinding-map.ts +2 -1
  69. package/src/ui/run-action-dispatcher.ts +2 -1
  70. package/src/ui/status-colors.ts +2 -1
  71. package/src/ui/syntax-highlight.ts +2 -1
  72. package/src/ui/tool-render.ts +13 -3
  73. package/src/utils/fingerprint.ts +183 -0
  74. package/src/utils/fs-watch.ts +4 -2
  75. package/src/utils/gh-protocol.ts +2 -1
  76. package/src/utils/safe-paths.ts +6 -0
  77. package/src/workflows/discover-workflows.ts +5 -1
  78. package/src/workflows/intermediate-store.ts +173 -0
  79. package/src/workflows/workflow-config.ts +8 -0
  80. package/src/worktree/cleanup.ts +8 -5
  81. package/src/worktree/worktree-manager.ts +1 -1
@@ -29,6 +29,15 @@ export interface AgentToolResultDetails {
29
29
  results?: Array<{ agentId?: string; status?: string; output?: string; error?: string }>;
30
30
  }
31
31
 
32
+ /** Combined type for renderAgentToolResult — handles both nested details and flat result shapes */
33
+ interface AgentResultData extends AgentToolResultDetails {
34
+ agentId?: string;
35
+ status?: string;
36
+ error?: string;
37
+ output?: string;
38
+ runId?: string;
39
+ }
40
+
32
41
  // ── Helpers ─────────────────────────────────────────────────────────
33
42
 
34
43
  export function formatTokens(n: number): string {
@@ -45,7 +54,8 @@ export function formatDuration(ms: number): string {
45
54
  return s > 0 ? `${m}m${s}s` : `${m}m`;
46
55
  }
47
56
 
48
- export function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
57
+ /** @internal */
58
+ function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
49
59
  if (!contextWindow) return `${formatTokens(tokens)} ctx`;
50
60
  const pct = (tokens / contextWindow) * 100;
51
61
  const maxStr = contextWindow >= 1_000_000 ? `${(contextWindow / 1_000_000).toFixed(1)}M` : `${Math.round(contextWindow / 1000)}k`;
@@ -294,7 +304,7 @@ export function renderAgentToolResult(
294
304
  _options: unknown, theme: Theme, _context: unknown,
295
305
  ): Component {
296
306
  // Handle both nested details and flattened result shape
297
- const d = (result as any).details ?? result;
307
+ const d = (result.details ?? result) as AgentResultData;
298
308
  const c = new Container();
299
309
  const w = 116;
300
310
 
@@ -351,7 +361,7 @@ export function renderAgentToolResult(
351
361
  function extractText(content: unknown[] | undefined): string {
352
362
  if (!content) return "(no output)";
353
363
  if (!Array.isArray(content)) return String(content);
354
- return content.filter((c: any) => c?.type === "text").map((c: any) => c?.text ?? "").join("\n") || "(no output)";
364
+ return content.filter((c): c is Record<string, unknown> => typeof c === "object" && c !== null && (c as Record<string, unknown>).type === "text").map((c) => String((c as Record<string, unknown>).text ?? "")).join("\n") || "(no output)";
355
365
  }
356
366
 
357
367
  function parseArgs(argsStr: string | undefined): Record<string, unknown> {
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Incremental fingerprinting — detect file changes to skip unchanged work.
3
+ *
4
+ * Pattern origin: Understand-Anything/packages/core/src/fingerprint.ts
5
+ * Content hash + structural signature per file. Change classifier:
6
+ * NONE (unchanged), COSMETIC (whitespace/comments only), STRUCTURAL (code changed).
7
+ * Only STRUCTURAL changes trigger re-analysis.
8
+ */
9
+
10
+ import { createHash, type Hash } from "node:crypto";
11
+ import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
12
+ import { logInternalError } from "../utils/internal-error.ts";
13
+
14
+ // ── Types ────────────────────────────────────────────────────────────────
15
+
16
+ export type ChangeClass = "NONE" | "COSMETIC" | "STRUCTURAL";
17
+
18
+ export interface FileFingerprint {
19
+ path: string;
20
+ contentHash: string;
21
+ structuralSignature: string;
22
+ lastModified: number; // mtime ms
23
+ changeClass: ChangeClass;
24
+ }
25
+
26
+ export interface FingerprintDelta {
27
+ added: string[];
28
+ removed: string[];
29
+ modified: FileFingerprint[]; // only STRUCTURAL changes
30
+ unchanged: number;
31
+ }
32
+
33
+ // ── Fingerprinting ───────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Compute SHA-256 content hash of a file.
37
+ */
38
+ export function computeContentHash(filePath: string): string {
39
+ try {
40
+ const content = readFileSync(filePath);
41
+ return createHash("sha256").update(content).digest("hex");
42
+ } catch {
43
+ return "";
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Extract a structural signature from source code.
49
+ *
50
+ * Captures function signatures, class methods, and import specifiers.
51
+ * Ignores whitespace, comments, and string content.
52
+ * Returns a hash of the structural elements.
53
+ */
54
+ export function computeStructuralSignature(content: string, filePath: string): string {
55
+ const lines = content.split("\n");
56
+ const structural: string[] = [];
57
+
58
+ for (const line of lines) {
59
+ const trimmed = line.trim();
60
+
61
+ // Skip empty lines and comments
62
+ if (trimmed.length === 0) continue;
63
+ if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) continue;
64
+
65
+ // Capture structural lines
66
+ if (
67
+ // Function/method declarations
68
+ /^(export\s+)?(async\s+)?function\s/.test(trimmed) ||
69
+ /^\w+\s*\(/.test(trimmed) && !/^(if|for|while|switch|return|throw|await)/.test(trimmed) ||
70
+ // Class declarations
71
+ /^(export\s+)?(abstract\s+)?class\s/.test(trimmed) ||
72
+ /^(public|private|protected|static|readonly|abstract)\s/.test(trimmed) ||
73
+ // Interface/type declarations
74
+ /^(export\s+)?(interface|type)\s/.test(trimmed) ||
75
+ // Import declarations
76
+ /^import\s/.test(trimmed) ||
77
+ // Export declarations
78
+ /^export\s+(default\s+)?(const|let|var|function|class|interface|type|enum)\s/.test(trimmed)
79
+ ) {
80
+ structural.push(trimmed);
81
+ }
82
+ }
83
+
84
+ return createHash("sha256").update(structural.join("\n")).digest("hex");
85
+ }
86
+
87
+ /**
88
+ * Classify the change between two fingerprints.
89
+ */
90
+ export function classifyChange(previous: FileFingerprint | undefined, current: FileFingerprint): ChangeClass {
91
+ if (!previous) return "STRUCTURAL"; // New file
92
+
93
+ if (previous.contentHash === current.contentHash) return "NONE";
94
+ if (previous.structuralSignature === current.structuralSignature) return "COSMETIC";
95
+ return "STRUCTURAL";
96
+ }
97
+
98
+ /**
99
+ * Compute fingerprint for a single file.
100
+ */
101
+ export function fingerprintFile(filePath: string): FileFingerprint {
102
+ const content = readFileSync(filePath, "utf-8");
103
+ const stat = statSync(filePath);
104
+
105
+ return {
106
+ path: filePath,
107
+ contentHash: computeContentHash(filePath),
108
+ structuralSignature: computeStructuralSignature(content, filePath),
109
+ lastModified: stat.mtimeMs,
110
+ changeClass: "NONE", // will be classified during delta computation
111
+ };
112
+ }
113
+
114
+ // ── Fingerprint Store ────────────────────────────────────────────────────
115
+
116
+ const MAX_FINGERPRINTS = 10000;
117
+
118
+ /**
119
+ * Load fingerprint baseline from a JSON file.
120
+ */
121
+ export function loadFingerprintBaseline(storePath: string): Map<string, FileFingerprint> {
122
+ const map = new Map<string, FileFingerprint>();
123
+ if (!existsSync(storePath)) return map;
124
+
125
+ try {
126
+ const data = JSON.parse(readFileSync(storePath, "utf-8")) as FileFingerprint[];
127
+ for (const fp of data) {
128
+ if (map.size >= MAX_FINGERPRINTS) break;
129
+ map.set(fp.path, fp);
130
+ }
131
+ } catch (error) {
132
+ logInternalError("fingerprint.load", error, `storePath=${storePath}`);
133
+ }
134
+ return map;
135
+ }
136
+
137
+ /**
138
+ * Save fingerprint baseline to a JSON file.
139
+ */
140
+ export function saveFingerprintBaseline(storePath: string, fingerprints: Map<string, FileFingerprint>): void {
141
+ const entries = [...fingerprints.entries()].slice(0, MAX_FINGERPRINTS).map(([, fp]) => fp);
142
+ writeFileSync(storePath, JSON.stringify(entries, null, 2), "utf-8");
143
+ }
144
+
145
+ /**
146
+ * Compute delta between baseline and current fingerprints.
147
+ *
148
+ * Only returns STRUCTURAL changes in the `modified` field.
149
+ */
150
+ export function computeFingerprintDelta(
151
+ baseline: Map<string, FileFingerprint>,
152
+ current: Map<string, FileFingerprint>,
153
+ ): FingerprintDelta {
154
+ const added: string[] = [];
155
+ const removed: string[] = [];
156
+ const modified: FileFingerprint[] = [];
157
+ let unchanged = 0;
158
+
159
+ // Find added and modified
160
+ for (const [path, currentFp] of current) {
161
+ const prev = baseline.get(path);
162
+ const changeClass = classifyChange(prev, currentFp);
163
+
164
+ if (!prev) {
165
+ added.push(path);
166
+ } else if (changeClass === "STRUCTURAL") {
167
+ modified.push({ ...currentFp, changeClass: "STRUCTURAL" });
168
+ } else if (changeClass === "COSMETIC") {
169
+ unchanged++; // cosmetic = treated as unchanged for re-analysis
170
+ } else {
171
+ unchanged++;
172
+ }
173
+ }
174
+
175
+ // Find removed
176
+ for (const path of baseline.keys()) {
177
+ if (!current.has(path)) {
178
+ removed.push(path);
179
+ }
180
+ }
181
+
182
+ return { added, removed, modified, unchanged };
183
+ }
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { FSWatcher, WatchListener } from "node:fs";
4
4
 
5
- export const FS_WATCH_RETRY_DELAY_MS = 5000;
5
+ /** @internal */
6
+ const FS_WATCH_RETRY_DELAY_MS = 5000;
6
7
 
7
8
  export function closeWatcher(watcher: FSWatcher | null | undefined): void {
8
9
  if (!watcher) {
@@ -85,4 +86,5 @@ export function watchCrewState(
85
86
  }
86
87
 
87
88
  // Re-export path helper so callers don't pull node:path just for join.
88
- export const joinPath = path.join;
89
+ /** @internal */
90
+ const joinPath = path.join;
@@ -473,7 +473,8 @@ export function resolveGitHubUrl(parsed: Parsed, scheme: "issue" | "pr", cwd: st
473
473
  * Resolve a raw `issue://` or `pr://` URL string.
474
474
  * Convenience wrapper combining parse + resolve.
475
475
  */
476
- export function resolveGitHubProtocol(raw: string, scheme: "issue" | "pr", cwd: string): GhResult<unknown> {
476
+ /** @internal */
477
+ function resolveGitHubProtocol(raw: string, scheme: "issue" | "pr", cwd: string): GhResult<unknown> {
477
478
  const parsed = parseGitHubUrl(raw, scheme);
478
479
  return resolveGitHubUrl(parsed, scheme, cwd);
479
480
  }
@@ -11,6 +11,9 @@ export function assertSafePathId(kind: string, value: string): string {
11
11
  }
12
12
 
13
13
  export function resolveContainedPath(baseDir: string, targetPath: string): string {
14
+ if (targetPath.includes('\0')) {
15
+ throw new Error(`Security: path contains null byte`);
16
+ }
14
17
  const base = path.resolve(baseDir);
15
18
  const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(base, targetPath);
16
19
  const relative = path.relative(base, resolved);
@@ -41,6 +44,9 @@ export function resolveRealContainedPath(baseDir: string, targetPath: string): s
41
44
  }
42
45
 
43
46
  export function resolveContainedRelativePath(baseDir: string, relativePath: string, kind = "path"): string {
47
+ if (relativePath.includes('\0')) {
48
+ throw new Error(`Security: path contains null byte: ${kind}`);
49
+ }
44
50
  const normalized = relativePath.replaceAll("\\", "/").replace(/^\.\/+/, "");
45
51
  if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`);
46
52
  return resolveContainedPath(baseDir, path.resolve(baseDir, normalized));
@@ -11,7 +11,7 @@ export interface WorkflowDiscoveryResult {
11
11
  project: WorkflowConfig[];
12
12
  }
13
13
 
14
- const STEP_CONFIG_KEYS = new Set(["role", "dependsOn", "parallelGroup", "output", "reads", "model", "skills", "progress", "worktree", "verify", "task"]);
14
+ const STEP_CONFIG_KEYS = new Set(["role", "dependsOn", "parallelGroup", "output", "reads", "model", "skills", "progress", "worktree", "verify", "task", "seedPaths", "preStepScript", "preStepArgs", "preStepTimeout"]);
15
15
 
16
16
  function parseStepSection(id: string, body: string): WorkflowStep | undefined {
17
17
  const lines = body.trim().split("\n");
@@ -50,6 +50,10 @@ function parseStepSection(id: string, body: string): WorkflowStep | undefined {
50
50
  progress: config.progress === "true" ? true : config.progress === "false" ? false : undefined,
51
51
  worktree: config.worktree === "true" ? true : config.worktree === "false" ? false : undefined,
52
52
  verify: config.verify === "true" ? true : config.verify === "false" ? false : undefined,
53
+ seedPaths: parseCsv(config.seedPaths) || undefined,
54
+ preStepScript: config.preStepScript || undefined,
55
+ preStepArgs: parseCsv(config.preStepArgs) || undefined,
56
+ preStepTimeout: parseOptionalInteger(config.preStepTimeout) ?? undefined,
53
57
  };
54
58
  }
55
59
 
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Phase-gated intermediate store — persist workflow step outputs to disk.
3
+ *
4
+ * Pattern origin: Understand-Anything 7-phase pipeline where each phase
5
+ * writes structured JSON to intermediate/ directory. Phase N reads Phase N-1
6
+ * output from disk (not context). Enables:
7
+ * - Context isolation between steps
8
+ * - Incremental re-runs (skip completed phases)
9
+ * - Debugging (inspect intermediate outputs)
10
+ */
11
+
12
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from "node:fs";
13
+ import path from "node:path";
14
+ import { logInternalError } from "../utils/internal-error.ts";
15
+
16
+ // ── Types ────────────────────────────────────────────────────────────────
17
+
18
+ export interface IntermediateOutput {
19
+ phase: string;
20
+ stepId: string;
21
+ timestamp: string;
22
+ data: unknown;
23
+ }
24
+
25
+ export interface IntermediateStoreConfig {
26
+ /** Root directory for intermediates (e.g., ".crew/intermediate/") */
27
+ intermediateDir: string;
28
+ /** File patterns to preserve across runs (e.g., ["scan-result.json"]) */
29
+ preservePatterns: string[];
30
+ }
31
+
32
+ // ── Store Operations ─────────────────────────────────────────────────────
33
+
34
+ const DEFAULT_CONFIG: IntermediateStoreConfig = {
35
+ intermediateDir: ".crew/intermediate",
36
+ preservePatterns: [],
37
+ };
38
+
39
+ /**
40
+ * Ensure the intermediate directory exists.
41
+ */
42
+ export function ensureIntermediateDir(config: Partial<IntermediateStoreConfig> = {}): string {
43
+ const dir = config.intermediateDir ?? DEFAULT_CONFIG.intermediateDir;
44
+ mkdirSync(dir, { recursive: true });
45
+ return dir;
46
+ }
47
+
48
+ /**
49
+ * Write an intermediate output for a phase.
50
+ *
51
+ * @param config - Store configuration
52
+ * @param phase - Phase name (e.g., "explore", "analyze")
53
+ * @param stepId - Step ID for correlation
54
+ * @param data - Phase output data
55
+ * @returns Path to the written file
56
+ */
57
+ export function writeIntermediate(
58
+ config: Partial<IntermediateStoreConfig>,
59
+ phase: string,
60
+ stepId: string,
61
+ data: unknown,
62
+ ): string {
63
+ const dir = ensureIntermediateDir(config);
64
+ const filename = `${phase}-${stepId}.json`;
65
+ const filePath = path.join(dir, filename);
66
+
67
+ const output: IntermediateOutput = {
68
+ phase,
69
+ stepId,
70
+ timestamp: new Date().toISOString(),
71
+ data,
72
+ };
73
+
74
+ writeFileSync(filePath, JSON.stringify(output, null, 2), "utf-8");
75
+ return filePath;
76
+ }
77
+
78
+ /**
79
+ * Read an intermediate output for a phase.
80
+ *
81
+ * @param config - Store configuration
82
+ * @param phase - Phase name
83
+ * @param stepId - Step ID
84
+ * @returns Parsed intermediate output, or undefined if not found
85
+ */
86
+ export function readIntermediate(
87
+ config: Partial<IntermediateStoreConfig>,
88
+ phase: string,
89
+ stepId: string,
90
+ ): IntermediateOutput | undefined {
91
+ const dir = config.intermediateDir ?? DEFAULT_CONFIG.intermediateDir;
92
+ const filename = `${phase}-${stepId}.json`;
93
+ const filePath = path.join(dir, filename);
94
+
95
+ if (!existsSync(filePath)) return undefined;
96
+
97
+ try {
98
+ const content = readFileSync(filePath, "utf-8");
99
+ return JSON.parse(content) as IntermediateOutput;
100
+ } catch (error) {
101
+ logInternalError("intermediate-store.read", error, `filePath=${filePath}`);
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Read the most recent intermediate output for any phase.
108
+ *
109
+ * Useful when you don't know the exact stepId but want the latest
110
+ * output from a phase (e.g., for incremental re-runs).
111
+ */
112
+ export function readLatestIntermediate(
113
+ config: Partial<IntermediateStoreConfig>,
114
+ phase: string,
115
+ ): IntermediateOutput | undefined {
116
+ const dir = config.intermediateDir ?? DEFAULT_CONFIG.intermediateDir;
117
+ if (!existsSync(dir)) return undefined;
118
+
119
+ const files = readdirSync(dir)
120
+ .filter((f) => f.startsWith(`${phase}-`) && f.endsWith(".json"))
121
+ .sort()
122
+ .reverse(); // most recent first
123
+
124
+ if (files.length === 0) return undefined;
125
+
126
+ try {
127
+ const content = readFileSync(path.join(dir, files[0]!), "utf-8");
128
+ return JSON.parse(content) as IntermediateOutput;
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Clean up intermediate files, preserving specified patterns.
136
+ *
137
+ * @param config - Store configuration
138
+ * @returns Number of files removed
139
+ */
140
+ export function cleanupIntermediates(config: Partial<IntermediateStoreConfig> = {}): number {
141
+ const dir = config.intermediateDir ?? DEFAULT_CONFIG.intermediateDir;
142
+ const preserve = config.preservePatterns ?? DEFAULT_CONFIG.preservePatterns;
143
+
144
+ if (!existsSync(dir)) return 0;
145
+
146
+ const files = readdirSync(dir);
147
+ let removed = 0;
148
+
149
+ for (const file of files) {
150
+ const shouldPreserve = preserve.some((pattern) => file.includes(pattern));
151
+ if (!shouldPreserve) {
152
+ try {
153
+ unlinkSync(path.join(dir, file));
154
+ removed++;
155
+ } catch (error) {
156
+ logInternalError("intermediate-store.cleanup", error, `file=${file}`);
157
+ }
158
+ }
159
+ }
160
+
161
+ return removed;
162
+ }
163
+
164
+ /**
165
+ * Check if a phase has completed (intermediate exists).
166
+ */
167
+ export function hasPhaseCompleted(
168
+ config: Partial<IntermediateStoreConfig>,
169
+ phase: string,
170
+ stepId: string,
171
+ ): boolean {
172
+ return readIntermediate(config, phase, stepId) !== undefined;
173
+ }
@@ -17,6 +17,14 @@ export interface WorkflowStep {
17
17
  /** Per-step files to overlay into the worktree (in addition to global worktree.seedPaths).
18
18
  * Useful when only certain steps need access to local drafts or scripts. */
19
19
  seedPaths?: string[];
20
+ /** Path to a deterministic script to run before dispatching the LLM worker.
21
+ * Script stdout is injected into the worker's prompt as context.
22
+ * Pattern origin: Understand-Anything deterministic pre-step pattern. */
23
+ preStepScript?: string;
24
+ /** Arguments for preStepScript. Passed as positional args. */
25
+ preStepArgs?: string[];
26
+ /** Timeout in ms for preStepScript. Default: 30000. */
27
+ preStepTimeout?: number;
20
28
  }
21
29
 
22
30
  export interface WorkflowConfig {
@@ -5,6 +5,7 @@ import type { TeamRunManifest } from "../state/types.ts";
5
5
  import { writeArtifact } from "../state/artifact-store.ts";
6
6
  import { projectCrewRoot } from "../utils/paths.ts";
7
7
  import { DEFAULT_PATHS } from "../config/defaults.ts";
8
+ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
8
9
 
9
10
  export interface WorktreeCleanupResult {
10
11
  removed: string[];
@@ -14,8 +15,10 @@ export interface WorktreeCleanupResult {
14
15
  committedBranches: string[];
15
16
  }
16
17
 
18
+ const GIT_SAFE_ENV = { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "PI_*", "PI_CREW_*"] }), LANG: "C", LC_ALL: "C" };
19
+
17
20
  function git(cwd: string, args: string[]): string {
18
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true }).trim();
21
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true }).trim();
19
22
  }
20
23
 
21
24
  function isDirty(worktreePath: string): boolean {
@@ -58,21 +61,21 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
58
61
  if (dirty) {
59
62
  // Commit changes to a branch instead of just preserving the worktree
60
63
  try {
61
- execFileSync("git", ["add", "-A"], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
64
+ execFileSync("git", ["add", "-A"], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
62
65
  let safeDesc = entry.name.slice(0, 200);
63
66
  // SECURITY: Strip any newlines that could be injected via a malicious worktree name
64
67
  // to prevent newline injection in git commit messages
65
68
  if (safeDesc.includes("\n")) {
66
69
  safeDesc = safeDesc.replace(/[\r\n]+/g, " ");
67
70
  }
68
- execFileSync("git", ["commit", "-m", `pi-crew: ${safeDesc}`], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
71
+ execFileSync("git", ["commit", "-m", `pi-crew: ${safeDesc}`], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
69
72
  // Create branch in the main repo pointing to this worktree's HEAD
70
73
  try {
71
- execFileSync("git", ["branch", branchName], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
74
+ execFileSync("git", ["branch", branchName], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
72
75
  } catch {
73
76
  // Branch already exists — use timestamp suffix
74
77
  const tsBranch = `${branchName}-${Date.now()}`;
75
- execFileSync("git", ["branch", tsBranch], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
78
+ execFileSync("git", ["branch", tsBranch], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
76
79
  }
77
80
  result.committedBranches.push(branchName);
78
81
  // Remove the worktree (branch persists)
@@ -25,7 +25,7 @@ export interface WorktreeDiffStat {
25
25
  }
26
26
 
27
27
  function git(cwd: string, args: string[]): string {
28
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true }).trim();
28
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "PI_*", "PI_CREW_*"] }), LANG: "C", LC_ALL: "C" }, windowsHide: true }).trim();
29
29
  }
30
30
 
31
31
  function sanitizeBranchPart(value: string): string {