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.
@@ -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 {