pi-crew 0.3.9 → 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,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
+ }
@@ -0,0 +1,199 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { TeamRunManifest, TeamTaskState } from "./types.ts";
4
+
5
+ export interface RunGraphNode {
6
+ id: string;
7
+ type: "run" | "task" | "agent" | "artifact" | "file";
8
+ name: string;
9
+ metadata?: Record<string, unknown>;
10
+ }
11
+
12
+ export interface RunGraphEdge {
13
+ source: string;
14
+ target: string;
15
+ type: "dependsOn" | "produces" | "runs" | "contains";
16
+ weight?: number;
17
+ }
18
+
19
+ export interface RunGraphLayer {
20
+ name: string;
21
+ nodeIds: string[];
22
+ }
23
+
24
+ export interface RunGraph {
25
+ version: "1.0.0";
26
+ runId: string;
27
+ team: string;
28
+ workflow: string;
29
+ createdAt: string;
30
+ completedAt?: string;
31
+ status: string;
32
+ nodes: RunGraphNode[];
33
+ edges: RunGraphEdge[];
34
+ layers: RunGraphLayer[];
35
+ }
36
+
37
+ /**
38
+ * Build a unified run graph from manifest + tasks.
39
+ * Consolidates state into a single graph JSON for dashboard/API use.
40
+ */
41
+ export function buildRunGraph(
42
+ manifest: TeamRunManifest,
43
+ tasks: TeamTaskState[],
44
+ ): RunGraph {
45
+ const nodes: RunGraphNode[] = [];
46
+ const edges: RunGraphEdge[] = [];
47
+ const nodeIds = new Set<string>();
48
+
49
+ // Add run node
50
+ const runId = manifest.runId;
51
+ nodes.push({
52
+ id: `run:${runId}`,
53
+ type: "run",
54
+ name: manifest.goal ?? runId,
55
+ metadata: {
56
+ team: manifest.team,
57
+ workflow: manifest.workflow,
58
+ status: manifest.status,
59
+ createdAt: manifest.createdAt,
60
+ completedAt: (manifest as Record<string, unknown>).completedAt,
61
+ },
62
+ });
63
+ nodeIds.add(`run:${runId}`);
64
+
65
+ // Add task nodes
66
+ for (const task of tasks) {
67
+ const taskId = `task:${task.id}`;
68
+ if (nodeIds.has(taskId)) continue;
69
+ nodeIds.add(taskId);
70
+
71
+ nodes.push({
72
+ id: taskId,
73
+ type: "task",
74
+ name: task.role,
75
+ metadata: {
76
+ phase: (task as Record<string, unknown>).phase,
77
+ status: task.status,
78
+ agentModel: (task as Record<string, unknown>).agentModel,
79
+ usage: (task as Record<string, unknown>).usage,
80
+ startedAt: task.startedAt,
81
+ finishedAt: task.finishedAt,
82
+ },
83
+ });
84
+
85
+ // Edge from run to task
86
+ edges.push({
87
+ source: `run:${runId}`,
88
+ target: taskId,
89
+ type: "contains",
90
+ });
91
+
92
+ // Edges from dependencies
93
+ for (const dep of task.dependsOn ?? []) {
94
+ edges.push({
95
+ source: `task:${dep}`,
96
+ target: taskId,
97
+ type: "dependsOn",
98
+ weight: 1.0,
99
+ });
100
+ }
101
+
102
+ // Edge from task to agent (if we have agent model info)
103
+ const agentModel = (task as Record<string, unknown>).agentModel as string | undefined;
104
+ if (agentModel) {
105
+ const agentId = `agent:${agentModel.replace(/[^a-zA-Z0-9-_]/g, "_")}`;
106
+ if (!nodeIds.has(agentId)) {
107
+ nodeIds.add(agentId);
108
+ nodes.push({ id: agentId, type: "agent", name: agentModel });
109
+ }
110
+ edges.push({
111
+ source: agentId,
112
+ target: taskId,
113
+ type: "runs",
114
+ weight: 0.9,
115
+ });
116
+ }
117
+ }
118
+
119
+ // Group by layer (based on phase)
120
+ const layerMap = new Map<string, string[]>();
121
+ for (const task of tasks) {
122
+ const phase = ((task as Record<string, unknown>).phase as string) ?? "unknown";
123
+ if (!layerMap.has(phase)) layerMap.set(phase, []);
124
+ layerMap.get(phase)!.push(`task:${task.id}`);
125
+ }
126
+
127
+ const layers: RunGraphLayer[] = [...layerMap.entries()].map(([name, nodeIdList]) => ({
128
+ name,
129
+ nodeIds: nodeIdList,
130
+ }));
131
+
132
+ return {
133
+ version: "1.0.0",
134
+ runId,
135
+ team: manifest.team ?? "unknown",
136
+ workflow: manifest.workflow ?? "unknown",
137
+ createdAt: manifest.createdAt,
138
+ completedAt: (manifest as Record<string, unknown>).completedAt as string | undefined,
139
+ status: manifest.status,
140
+ nodes,
141
+ edges,
142
+ layers,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Save run graph to disk in .crew/graphs/
148
+ */
149
+ export function saveRunGraph(graph: RunGraph, cwd: string): string {
150
+ const crewRoot = path.join(cwd, ".crew");
151
+ const graphsDir = path.join(crewRoot, "graphs");
152
+
153
+ if (!fs.existsSync(graphsDir)) {
154
+ fs.mkdirSync(graphsDir, { recursive: true });
155
+ }
156
+
157
+ const graphPath = path.join(graphsDir, `${graph.runId}.json`);
158
+ fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2), "utf-8");
159
+
160
+ return graphPath;
161
+ }
162
+
163
+ /**
164
+ * Load run graph from disk.
165
+ */
166
+ export function loadRunGraph(cwd: string, runId: string): RunGraph | null {
167
+ const graphPath = path.join(cwd, ".crew", "graphs", `${runId}.json`);
168
+ if (!fs.existsSync(graphPath)) return null;
169
+
170
+ try {
171
+ return JSON.parse(fs.readFileSync(graphPath, "utf-8")) as RunGraph;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * List all archived run graphs.
179
+ */
180
+ export function listRunGraphs(cwd: string): string[] {
181
+ const graphsDir = path.join(cwd, ".crew", "graphs");
182
+ if (!fs.existsSync(graphsDir)) return [];
183
+
184
+ return fs.readdirSync(graphsDir)
185
+ .filter((f) => f.endsWith(".json"))
186
+ .map((f) => f.replace(/\.json$/, ""));
187
+ }
188
+
189
+ /**
190
+ * Build and save run graph from manifest + tasks.
191
+ */
192
+ export function buildAndSaveRunGraph(
193
+ manifest: TeamRunManifest,
194
+ tasks: TeamTaskState[],
195
+ cwd: string,
196
+ ): string {
197
+ const graph = buildRunGraph(manifest, tasks);
198
+ return saveRunGraph(graph, cwd);
199
+ }