pi-crew 0.4.0 → 0.5.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,232 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export interface Checkpoint {
5
+ runId: string;
6
+ taskId: string;
7
+ step: number;
8
+ context: string;
9
+ progress: string;
10
+ savedAt: number;
11
+ agentId: string;
12
+ agentModel?: string;
13
+ }
14
+
15
+ export interface CheckpointStore {
16
+ save(checkpoint: Checkpoint): void;
17
+ load(runId: string, taskId: string): Checkpoint | null;
18
+ delete(runId: string, taskId: string): void;
19
+ list(runId: string): Checkpoint[];
20
+ hasCheckpoint(runId: string, taskId: string): boolean;
21
+ }
22
+
23
+ interface CheckpointEntry {
24
+ checkpoints: Record<string, Checkpoint>;
25
+ }
26
+
27
+ /**
28
+ * File-based checkpoint store.
29
+ * Saves checkpoints as JSON files in .crew/state/runs/<runId>/checkpoints/
30
+ */
31
+ export class FileCheckpointStore implements CheckpointStore {
32
+ private readonly stateRoot: string;
33
+
34
+ constructor(stateRoot: string) {
35
+ this.stateRoot = stateRoot;
36
+ }
37
+
38
+ private checkpointDir(): string {
39
+ return path.join(this.stateRoot, "checkpoints");
40
+ }
41
+
42
+ private checkpointPath(taskId: string): string {
43
+ return path.join(this.checkpointDir(), `${taskId}.json`);
44
+ }
45
+
46
+ private ensureDir(): void {
47
+ const dir = this.checkpointDir();
48
+ if (!fs.existsSync(dir)) {
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ }
51
+ }
52
+
53
+ save(checkpoint: Checkpoint): void {
54
+ this.ensureDir();
55
+ const p = this.checkpointPath(checkpoint.taskId);
56
+ fs.writeFileSync(p, JSON.stringify(checkpoint, null, 2), "utf-8");
57
+ }
58
+
59
+ load(runId: string, taskId: string): Checkpoint | null {
60
+ const p = this.checkpointPath(taskId);
61
+ if (!fs.existsSync(p)) return null;
62
+
63
+ try {
64
+ const data = JSON.parse(fs.readFileSync(p, "utf-8")) as Checkpoint;
65
+ // Verify it's for the correct run
66
+ if (data.runId !== runId) return null;
67
+ return data;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ delete(runId: string, taskId: string): void {
74
+ const p = this.checkpointPath(taskId);
75
+ if (fs.existsSync(p)) {
76
+ try {
77
+ const data = JSON.parse(fs.readFileSync(p, "utf-8")) as Checkpoint;
78
+ if (data.runId === runId) {
79
+ fs.unlinkSync(p);
80
+ }
81
+ } catch {
82
+ // File existed but couldn't read — delete it anyway
83
+ try {
84
+ fs.unlinkSync(p);
85
+ } catch { /* ignore */ }
86
+ }
87
+ }
88
+ }
89
+
90
+ list(runId: string): Checkpoint[] {
91
+ const dir = this.checkpointDir();
92
+ if (!fs.existsSync(dir)) return [];
93
+
94
+ return fs.readdirSync(dir)
95
+ .filter((f) => f.endsWith(".json"))
96
+ .map((f) => {
97
+ try {
98
+ return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")) as Checkpoint;
99
+ } catch {
100
+ return null;
101
+ }
102
+ })
103
+ .filter((c): c is Checkpoint => c !== null && c.runId === runId);
104
+ }
105
+
106
+ hasCheckpoint(runId: string, taskId: string): boolean {
107
+ return this.load(runId, taskId) !== null;
108
+ }
109
+ }
110
+
111
+ const _stores = new Map<string, FileCheckpointStore>();
112
+
113
+ /**
114
+ * Get checkpoint store for a run's state root.
115
+ */
116
+ export function getCheckpointStore(stateRoot: string): CheckpointStore {
117
+ if (!_stores.has(stateRoot)) {
118
+ _stores.set(stateRoot, new FileCheckpointStore(stateRoot));
119
+ }
120
+ return _stores.get(stateRoot)!;
121
+ }
122
+
123
+ /**
124
+ * Clear all checkpoint stores (for testing).
125
+ */
126
+ export function clearCheckpointStores(): void {
127
+ _stores.clear();
128
+ }
129
+
130
+ /**
131
+ * Save a checkpoint during agent execution.
132
+ */
133
+ export function saveCheckpoint(
134
+ runId: string,
135
+ taskId: string,
136
+ step: number,
137
+ context: string,
138
+ progress: string,
139
+ agentId: string,
140
+ agentModel?: string,
141
+ ): void {
142
+ const checkpoint: Checkpoint = {
143
+ runId,
144
+ taskId,
145
+ step,
146
+ context,
147
+ progress,
148
+ savedAt: Date.now(),
149
+ agentId,
150
+ agentModel,
151
+ };
152
+
153
+ // State root is parent of checkpoints dir
154
+ const stateRoot = path.join(process.cwd(), ".crew/state/runs", runId);
155
+ const store = getCheckpointStore(stateRoot);
156
+ store.save(checkpoint);
157
+ }
158
+
159
+ /**
160
+ * Load a checkpoint for resuming.
161
+ */
162
+ export function loadCheckpoint(runId: string, taskId: string): Checkpoint | null {
163
+ const stateRoot = path.join(process.cwd(), ".crew/state/runs", runId);
164
+ const store = getCheckpointStore(stateRoot);
165
+ return store.load(runId, taskId);
166
+ }
167
+
168
+ /**
169
+ * Delete a checkpoint after successful completion.
170
+ */
171
+ export function clearCheckpoint(runId: string, taskId: string): void {
172
+ const stateRoot = path.join(process.cwd(), ".crew/state/runs", runId);
173
+ const store = getCheckpointStore(stateRoot);
174
+ store.delete(runId, taskId);
175
+ }
176
+
177
+ /**
178
+ * Check if a checkpoint exists for a task.
179
+ */
180
+ export function hasCheckpoint(runId: string, taskId: string): boolean {
181
+ const stateRoot = path.join(process.cwd(), ".crew/state/runs", runId);
182
+ const store = getCheckpointStore(stateRoot);
183
+ return store.hasCheckpoint(runId, taskId);
184
+ }
185
+
186
+ /**
187
+ * List all checkpoints for a run.
188
+ */
189
+ export function listCheckpoints(runId: string): Checkpoint[] {
190
+ const stateRoot = path.join(process.cwd(), ".crew/state/runs", runId);
191
+ const store = getCheckpointStore(stateRoot);
192
+ return store.list(runId);
193
+ }
194
+
195
+ /**
196
+ * Format a checkpoint for display.
197
+ */
198
+ export function formatCheckpoint(checkpoint: Checkpoint): string {
199
+ return [
200
+ `## Checkpoint: ${checkpoint.taskId}`,
201
+ "",
202
+ `**Agent:** ${checkpoint.agentId}`,
203
+ checkpoint.agentModel ? `**Model:** ${checkpoint.agentModel}` : "",
204
+ "",
205
+ `**Progress:** ${checkpoint.progress}`,
206
+ "",
207
+ `**Step:** ${checkpoint.step}`,
208
+ `**Saved:** ${new Date(checkpoint.savedAt).toISOString()}`,
209
+ "",
210
+ `**Context:** ${checkpoint.context.slice(0, 300)}${checkpoint.context.length > 300 ? "..." : ""}`,
211
+ ].filter(Boolean).join("\n");
212
+ }
213
+
214
+ /**
215
+ * Format all checkpoints for a run.
216
+ */
217
+ export function formatAllCheckpoints(runId: string): string {
218
+ const checkpoints = listCheckpoints(runId);
219
+ if (checkpoints.length === 0) {
220
+ return `No checkpoints found for run ${runId}`;
221
+ }
222
+
223
+ return [
224
+ `# Checkpoints: ${runId}`,
225
+ "",
226
+ ...checkpoints.map((cp, i) =>
227
+ `${i + 1}. **${cp.taskId}** — ${cp.progress} (${new Date(cp.savedAt).toLocaleString()})`,
228
+ ),
229
+ "",
230
+ `Use \`team action='resume' runId=${runId} taskId=<taskId>\` to resume.`,
231
+ ].join("\n");
232
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Auto-initialize .crew directory structure and .gitignore entries.
3
+ * Called on first team run in a workspace to ensure all required
4
+ * directories and files exist.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { projectCrewRoot } from "../utils/paths.ts";
9
+ import { updateGitignore } from "./gitignore-manager.ts";
10
+
11
+ /** README content for the .crew directory. */
12
+ const CREW_README = `# .crew — pi-crew Runtime Directory
13
+
14
+ This directory contains pi-crew runtime state and artifacts.
15
+
16
+ ## What's Here
17
+
18
+ | Directory | Purpose | Commit? |
19
+ |-----------|---------|---------|
20
+ | \`state/runs/\` | Run manifests, tasks, events | No |
21
+ | \`state/subagents/\` | Subagent state | No |
22
+ | \`artifacts/\` | Run outputs (test files, docs, etc.) | Optional |
23
+ | \`cache/\` | Cached run results (fingerprint-based) | No |
24
+ | \`graphs/\` | Archived run graphs | Optional |
25
+ | \`audit/\` | Security event logs | No |
26
+
27
+ ## Cleanup
28
+
29
+ To prune old runs:
30
+ \`\`\`bash
31
+ team action='prune' keep=5
32
+ \`\`\`
33
+
34
+ To clear cache:
35
+ \`\`\`bash
36
+ team action='cache' action='clear'
37
+ \`\`\`
38
+ `;
39
+
40
+ /**
41
+ * Ensure the .crew directory structure exists with all required subdirectories,
42
+ * placeholder files, README, and .gitignore entries.
43
+ *
44
+ * Uses `projectCrewRoot()` to resolve the correct root (`.crew/` or `.pi/teams/`
45
+ * for legacy projects). Idempotent — safe to call multiple times.
46
+ */
47
+ export async function ensureCrewDirectory(cwd: string): Promise<void> {
48
+ const crewRoot = projectCrewRoot(cwd);
49
+
50
+ // 1. Create directory structure
51
+ const dirs = [
52
+ crewRoot,
53
+ path.join(crewRoot, "state", "runs"),
54
+ path.join(crewRoot, "state", "subagents"),
55
+ path.join(crewRoot, "artifacts"),
56
+ path.join(crewRoot, "cache"),
57
+ path.join(crewRoot, "graphs"),
58
+ path.join(crewRoot, "audit"),
59
+ ];
60
+
61
+ for (const dir of dirs) {
62
+ if (!fs.existsSync(dir)) {
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ }
65
+ }
66
+
67
+ // 2. Create .gitkeep placeholders in directories that should be tracked
68
+ const placeholders = [
69
+ path.join(crewRoot, "artifacts", ".gitkeep"),
70
+ path.join(crewRoot, "cache", ".gitkeep"),
71
+ path.join(crewRoot, "graphs", ".gitkeep"),
72
+ path.join(crewRoot, "audit", ".gitkeep"),
73
+ ];
74
+
75
+ for (const placeholder of placeholders) {
76
+ if (!fs.existsSync(placeholder)) {
77
+ fs.writeFileSync(placeholder, "", "utf-8");
78
+ }
79
+ }
80
+
81
+ // 3. Write README.md (always overwrite to keep it current)
82
+ fs.writeFileSync(path.join(crewRoot, "README.md"), CREW_README, "utf-8");
83
+
84
+ // 4. Update .gitignore — resolve project root to place .gitignore correctly
85
+ // Find the repo root to place .gitignore at the project root (not inside .crew)
86
+ const repoRoot = findRepoRootForGitignore(cwd);
87
+ if (repoRoot) {
88
+ const gitignorePath = path.join(repoRoot, ".gitignore");
89
+ await updateGitignore(gitignorePath);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Find the appropriate project root for placing the .gitignore.
95
+ * Walks up from cwd to find a directory with project markers.
96
+ */
97
+ function findRepoRootForGitignore(cwd: string): string | undefined {
98
+ // Use the same project root markers as paths.ts
99
+ const dirMarkers = [".git", ".pi", ".crew", ".hg", ".svn"];
100
+ const fileMarkers = [
101
+ "package.json",
102
+ "pyproject.toml",
103
+ "Cargo.toml",
104
+ "go.mod",
105
+ ];
106
+ const root = path.parse(cwd).root;
107
+ let current = path.resolve(cwd);
108
+ while (current !== root) {
109
+ for (const marker of dirMarkers) {
110
+ if (fs.existsSync(path.join(current, marker))) return current;
111
+ }
112
+ for (const marker of fileMarkers) {
113
+ if (fs.existsSync(path.join(current, marker))) return current;
114
+ }
115
+ const parent = path.dirname(current);
116
+ if (parent === current) break;
117
+ current = parent;
118
+ }
119
+ // No project root found — don't create .gitignore
120
+ return undefined;
121
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Manage .gitignore entries for the .crew directory.
3
+ * Only adds entries if not already present; preserves existing content.
4
+ */
5
+ import * as fs from "node:fs";
6
+
7
+ /**
8
+ * Entries to add to .gitignore for .crew directory management.
9
+ *
10
+ * - `/.crew/` and `/.crew` ignore the core state directory.
11
+ * - Exceptions allow optional commit of artifacts/ and graphs/ (including their .gitkeep).
12
+ */
13
+ const CREW_GITIGNORE_ENTRIES = [
14
+ "/.crew/",
15
+ "/.crew",
16
+ "!.crew/artifacts/",
17
+ "!.crew/graphs/",
18
+ "!.crew/artifacts/.gitkeep",
19
+ "!.crew/graphs/.gitkeep",
20
+ ];
21
+
22
+ /**
23
+ * Update .gitignore with .crew entries. Creates the file if it doesn't exist.
24
+ * Preserves all existing content.
25
+ */
26
+ export async function updateGitignore(gitignorePath: string): Promise<void> {
27
+ if (!fs.existsSync(gitignorePath)) {
28
+ fs.writeFileSync(
29
+ gitignorePath,
30
+ CREW_GITIGNORE_ENTRIES.join("\n") + "\n",
31
+ "utf-8",
32
+ );
33
+ return;
34
+ }
35
+
36
+ const current = fs.readFileSync(gitignorePath, "utf-8");
37
+ const existingLines = new Set(
38
+ current.split("\n").map((line) => line.trim()),
39
+ );
40
+
41
+ let appended = "";
42
+ for (const entry of CREW_GITIGNORE_ENTRIES) {
43
+ if (!existingLines.has(entry)) {
44
+ appended += `\n${entry}`;
45
+ }
46
+ }
47
+
48
+ if (appended) {
49
+ fs.writeFileSync(gitignorePath, current + appended, "utf-8");
50
+ }
51
+ }
@@ -0,0 +1,176 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as crypto from "node:crypto";
4
+ import { projectCrewRoot } from "../utils/paths.ts";
5
+ import type { TeamTaskState } from "./types.ts";
6
+
7
+ const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
8
+
9
+ export interface CacheEntry {
10
+ key: string;
11
+ runId: string;
12
+ status: string;
13
+ tasks: TeamTaskState[];
14
+ cachedAt: number;
15
+ expiresAt: number;
16
+ goal: string;
17
+ team: string;
18
+ }
19
+
20
+ interface CacheIndex {
21
+ [cacheKey: string]: string;
22
+ }
23
+
24
+ /**
25
+ * Compute a cache key from run parameters.
26
+ * Uses SHA-256 hash of normalized goal + team + workflow.
27
+ */
28
+ export function computeRunCacheKey(goal: string, team: string, workflow: string, _cwd: string): string {
29
+ const normalized = goal.trim().toLowerCase().replace(/\s+/g, " ");
30
+ return crypto.createHash("sha256")
31
+ .update(normalized)
32
+ .update(team)
33
+ .update(workflow)
34
+ .digest("hex")
35
+ .slice(0, 16);
36
+ }
37
+
38
+ /**
39
+ * Get the cache directory path.
40
+ */
41
+ function cacheDir(cwd: string): string {
42
+ return path.join(projectCrewRoot(cwd), "cache");
43
+ }
44
+
45
+ /**
46
+ * Get cached run result if exists and valid.
47
+ * Returns null if cache miss or expired.
48
+ */
49
+ export function getCachedRun(cwd: string, cacheKey: string): CacheEntry | null {
50
+ const dir = cacheDir(cwd);
51
+ const indexPath = path.join(dir, "index.json");
52
+
53
+ if (!fs.existsSync(indexPath)) return null;
54
+
55
+ try {
56
+ const index = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
57
+ const entryPath = index[cacheKey];
58
+
59
+ if (!entryPath || !fs.existsSync(entryPath)) return null;
60
+
61
+ const entry = JSON.parse(fs.readFileSync(entryPath, "utf-8")) as CacheEntry;
62
+
63
+ if (Date.now() > entry.expiresAt) {
64
+ // Remove expired entry
65
+ try {
66
+ fs.unlinkSync(entryPath);
67
+ } catch { /* ignore */ }
68
+ delete index[cacheKey];
69
+ fs.writeFileSync(indexPath, JSON.stringify(index), "utf-8");
70
+ return null;
71
+ }
72
+
73
+ return entry;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Save run result to cache.
81
+ */
82
+ export function saveRunToCache(
83
+ cwd: string,
84
+ cacheKey: string,
85
+ runId: string,
86
+ status: string,
87
+ tasks: TeamTaskState[],
88
+ goal: string,
89
+ team: string,
90
+ ttlMs: number = DEFAULT_CACHE_TTL_MS,
91
+ ): void {
92
+ const dir = cacheDir(cwd);
93
+
94
+ if (!fs.existsSync(dir)) {
95
+ fs.mkdirSync(dir, { recursive: true });
96
+ }
97
+
98
+ const entry: CacheEntry = {
99
+ key: cacheKey,
100
+ runId,
101
+ status,
102
+ tasks,
103
+ cachedAt: Date.now(),
104
+ expiresAt: Date.now() + ttlMs,
105
+ goal,
106
+ team,
107
+ };
108
+
109
+ const entryPath = path.join(dir, `${cacheKey}.json`);
110
+ fs.writeFileSync(entryPath, JSON.stringify(entry), "utf-8");
111
+
112
+ // Update index
113
+ const indexPath = path.join(dir, "index.json");
114
+ const index: CacheIndex = fs.existsSync(indexPath)
115
+ ? JSON.parse(fs.readFileSync(indexPath, "utf-8"))
116
+ : {};
117
+
118
+ index[cacheKey] = entryPath;
119
+ fs.writeFileSync(indexPath, JSON.stringify(index), "utf-8");
120
+ }
121
+
122
+ /**
123
+ * Clear all cache entries.
124
+ */
125
+ export function clearCache(cwd: string): void {
126
+ const dir = cacheDir(cwd);
127
+ if (!fs.existsSync(dir)) return;
128
+
129
+ const indexPath = path.join(dir, "index.json");
130
+ if (fs.existsSync(indexPath)) {
131
+ try {
132
+ const index = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
133
+ for (const entryPath of Object.values(index)) {
134
+ try {
135
+ fs.unlinkSync(entryPath);
136
+ } catch { /* ignore */ }
137
+ }
138
+ fs.unlinkSync(indexPath);
139
+ } catch { /* ignore */ }
140
+ }
141
+
142
+ // Remove entry files not in index
143
+ const entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
144
+ for (const entry of entries) {
145
+ try {
146
+ fs.unlinkSync(path.join(dir, entry));
147
+ } catch { /* ignore */ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Get cache stats.
153
+ */
154
+ export function getCacheStats(cwd: string): { entries: number; sizeBytes: number } {
155
+ const dir = cacheDir(cwd);
156
+ if (!fs.existsSync(dir)) return { entries: 0, sizeBytes: 0 };
157
+
158
+ let sizeBytes = 0;
159
+ let entries = 0;
160
+ const indexPath = path.join(dir, "index.json");
161
+
162
+ if (fs.existsSync(indexPath)) {
163
+ try {
164
+ const index = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
165
+ entries = Object.keys(index).length;
166
+ for (const entryPath of Object.values(index)) {
167
+ try {
168
+ const stat = fs.statSync(entryPath);
169
+ sizeBytes += stat.size;
170
+ } catch { /* ignore */ }
171
+ }
172
+ } catch { /* ignore */ }
173
+ }
174
+
175
+ return { entries, sizeBytes };
176
+ }