pi-crew 0.5.24 → 0.6.0
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 +34 -0
- 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 +2 -0
- package/src/agents/discover-agents.ts +1 -0
- package/src/hooks/types.ts +14 -0
- package/src/runtime/chain-parser.ts +192 -0
- package/src/runtime/drift-detectors.ts +220 -0
- package/src/runtime/intercom-bridge.ts +178 -0
- package/src/runtime/plan-templates.ts +200 -0
- package/src/runtime/task-graph.ts +79 -0
- package/src/runtime/task-id.ts +148 -0
- package/src/runtime/task-runner/context-retrieval.ts +172 -0
- package/src/runtime/task-runner.ts +30 -1
- package/src/runtime/team-runner.ts +7 -0
- package/src/state/memory-store.ts +244 -0
- package/src/state/observation-store.ts +177 -0
- package/src/utils/fingerprint.ts +183 -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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation capture and compression system.
|
|
3
|
+
*
|
|
4
|
+
* Pattern origin: claude-mem — captures tool usage across sessions,
|
|
5
|
+
* compresses via AI, injects into future sessions.
|
|
6
|
+
*
|
|
7
|
+
* This module provides the observation store and compression logic.
|
|
8
|
+
* Actual capture hooks into the lifecycle events (Pattern 12).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
|
|
12
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
13
|
+
|
|
14
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface Observation {
|
|
17
|
+
tool: string;
|
|
18
|
+
input: string;
|
|
19
|
+
output: string;
|
|
20
|
+
filesRead: string[];
|
|
21
|
+
filesModified: string[];
|
|
22
|
+
timestamp: number;
|
|
23
|
+
sessionId: string;
|
|
24
|
+
taskId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CompressedObservation {
|
|
28
|
+
summary: string;
|
|
29
|
+
patterns: string[];
|
|
30
|
+
decisions: string[];
|
|
31
|
+
filesAffected: string[];
|
|
32
|
+
relevanceScore: number;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
sessionId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ObservationStoreConfig {
|
|
38
|
+
maxObservations: number;
|
|
39
|
+
maxCompressed: number;
|
|
40
|
+
privacyTags: string[]; // tags to strip before storage
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_CONFIG: ObservationStoreConfig = {
|
|
44
|
+
maxObservations: 1000,
|
|
45
|
+
maxCompressed: 200,
|
|
46
|
+
privacyTags: ["<private>", "<secret>", "<credentials>"],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ── Privacy ──────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Strip privacy-tagged content from a string.
|
|
53
|
+
*/
|
|
54
|
+
export function stripPrivacyTags(content: string, config = DEFAULT_CONFIG): string {
|
|
55
|
+
let result = content;
|
|
56
|
+
for (const tag of config.privacyTags) {
|
|
57
|
+
const openTag = tag;
|
|
58
|
+
const closeTag = tag.replace("<", "</");
|
|
59
|
+
// Remove everything between open and close tags
|
|
60
|
+
const regex = new RegExp(`${escapeRegex(openTag)}[\\s\\S]*?${escapeRegex(closeTag)}`, "gi");
|
|
61
|
+
result = result.replace(regex, "[REDACTED]");
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function escapeRegex(str: string): string {
|
|
67
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Observation Store ────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export class ObservationStore {
|
|
73
|
+
private observations: Observation[] = [];
|
|
74
|
+
private compressed: CompressedObservation[] = [];
|
|
75
|
+
private config: ObservationStoreConfig;
|
|
76
|
+
private storePath: string;
|
|
77
|
+
|
|
78
|
+
constructor(storePath: string, config: Partial<ObservationStoreConfig> = {}) {
|
|
79
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
80
|
+
this.storePath = storePath;
|
|
81
|
+
if (existsSync(storePath)) {
|
|
82
|
+
this.load();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Record a new observation.
|
|
88
|
+
*/
|
|
89
|
+
record(observation: Observation): void {
|
|
90
|
+
// Strip privacy
|
|
91
|
+
const sanitized: Observation = {
|
|
92
|
+
...observation,
|
|
93
|
+
input: stripPrivacyTags(observation.input, this.config),
|
|
94
|
+
output: stripPrivacyTags(observation.output, this.config),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
this.observations.push(sanitized);
|
|
98
|
+
|
|
99
|
+
// Enforce capacity
|
|
100
|
+
if (this.observations.length > this.config.maxObservations) {
|
|
101
|
+
this.observations = this.observations.slice(-this.config.maxObservations);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get recent observations.
|
|
107
|
+
*/
|
|
108
|
+
getRecent(count = 10): Observation[] {
|
|
109
|
+
return this.observations.slice(-count);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Store a compressed observation.
|
|
114
|
+
*/
|
|
115
|
+
addCompressed(compressed: CompressedObservation): void {
|
|
116
|
+
this.compressed.push(compressed);
|
|
117
|
+
|
|
118
|
+
if (this.compressed.length > this.config.maxCompressed) {
|
|
119
|
+
this.compressed = this.compressed.slice(-this.config.maxCompressed);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get compressed observations for injection.
|
|
125
|
+
*/
|
|
126
|
+
getCompressed(limit = 5): CompressedObservation[] {
|
|
127
|
+
return this.compressed
|
|
128
|
+
.sort((a, b) => b.relevanceScore - a.relevanceScore)
|
|
129
|
+
.slice(0, limit);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Format compressed observations for prompt injection.
|
|
134
|
+
*/
|
|
135
|
+
injectCompressed(limit = 5): string {
|
|
136
|
+
const items = this.getCompressed(limit);
|
|
137
|
+
if (items.length === 0) return "";
|
|
138
|
+
|
|
139
|
+
return "## Observations from Previous Sessions\n\n" +
|
|
140
|
+
items.map((o) =>
|
|
141
|
+
`### ${o.summary}\n` +
|
|
142
|
+
`Patterns: ${o.patterns.join(", ")}\n` +
|
|
143
|
+
`Decisions: ${o.decisions.join(", ")}\n` +
|
|
144
|
+
`Files: ${o.filesAffected.join(", ")}`,
|
|
145
|
+
).join("\n\n") +
|
|
146
|
+
"\n";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Persist to disk.
|
|
151
|
+
*/
|
|
152
|
+
save(): void {
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(this.storePath.substring(0, this.storePath.lastIndexOf("/")), { recursive: true });
|
|
155
|
+
writeFileSync(this.storePath, JSON.stringify({
|
|
156
|
+
observations: this.observations,
|
|
157
|
+
compressed: this.compressed,
|
|
158
|
+
}, null, 2), "utf-8");
|
|
159
|
+
} catch (error) {
|
|
160
|
+
logInternalError("observation-store.save", error, `path=${this.storePath}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get stats(): { observations: number; compressed: number } {
|
|
165
|
+
return { observations: this.observations.length, compressed: this.compressed.length };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private load(): void {
|
|
169
|
+
try {
|
|
170
|
+
const data = JSON.parse(readFileSync(this.storePath, "utf-8"));
|
|
171
|
+
if (Array.isArray(data.observations)) this.observations = data.observations;
|
|
172
|
+
if (Array.isArray(data.compressed)) this.compressed = data.compressed;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
logInternalError("observation-store.load", error, `path=${this.storePath}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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 {
|