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.
- package/CHANGELOG.md +99 -0
- package/README.md +13 -11
- package/docs/patterns/command-agent-skill.md +71 -0
- package/package.json +1 -1
- package/skills/council/SKILL.md +163 -0
- package/src/agents/agent-config.ts +4 -1
- package/src/agents/discover-agents.ts +1 -0
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/registration/commands.ts +2 -1
- package/src/extension/registration/subagent-tools.ts +2 -0
- package/src/extension/registration/team-tool.ts +2 -0
- package/src/extension/registration/viewers.ts +1 -0
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +9 -4
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool.ts +2 -1
- package/src/hooks/registry.ts +9 -1
- package/src/hooks/types.ts +14 -0
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +58 -3
- package/src/runtime/background-runner.ts +1 -1
- package/src/runtime/chain-parser.ts +192 -0
- package/src/runtime/chain-runner.ts +58 -0
- package/src/runtime/child-pi.ts +1 -1
- package/src/runtime/crew-agent-records.ts +4 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +3 -4
- package/src/runtime/dynamic-script-runner.ts +7 -7
- package/src/runtime/foreground-watchdog.ts +2 -2
- package/src/runtime/intercom-bridge.ts +178 -0
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/plan-templates.ts +200 -0
- package/src/runtime/post-checks.ts +10 -3
- package/src/runtime/run-drift.ts +220 -0
- package/src/runtime/sandbox.ts +26 -20
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +4 -2
- package/src/runtime/skill-instructions.ts +4 -1
- package/src/runtime/subagent-manager.ts +20 -2
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-graph.ts +79 -0
- package/src/runtime/task-id.ts +148 -0
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner/context-retrieval.ts +172 -0
- package/src/runtime/task-runner.ts +39 -1
- package/src/runtime/team-runner.ts +7 -0
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/state/contracts.ts +2 -1
- package/src/state/event-log.ts +16 -5
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +9 -2
- package/src/state/memory-store.ts +244 -0
- package/src/state/observation-store.ts +177 -0
- package/src/state/state-store.ts +4 -2
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- package/src/ui/run-action-dispatcher.ts +2 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/fingerprint.ts +183 -0
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/safe-paths.ts +6 -0
- package/src/workflows/discover-workflows.ts +5 -1
- package/src/workflows/intermediate-store.ts +173 -0
- package/src/workflows/workflow-config.ts +8 -0
- package/src/worktree/cleanup.ts +8 -5
- package/src/worktree/worktree-manager.ts +1 -1
package/src/ui/tool-render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|
package/src/utils/fs-watch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
89
|
+
/** @internal */
|
|
90
|
+
const joinPath = path.join;
|
package/src/utils/gh-protocol.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/utils/safe-paths.ts
CHANGED
|
@@ -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 {
|
package/src/worktree/cleanup.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|