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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] — Understand-Anything Patterns & New Features (2026-05-26)
4
+
5
+ ### New Features: P0-P6 from Understand-Anything Research
6
+
7
+ #### P0: Auto-Setup .crew Directory
8
+ - `ensureCrewDirectory()` creates full directory structure on first run
9
+ - `gitignore-manager.ts` auto-updates `.gitignore` with `.crew/` entries
10
+ - Creates: `state/runs`, `state/subagents`, `artifacts`, `cache`, `graphs`, `audit`
11
+ - README.md explains `.crew` directory purpose
12
+
13
+ #### P1: BM25 Agent/Team Search
14
+ - `BM25Search` class with configurable k1/b parameters
15
+ - `searchAgents(query)` — ranked agent search by name/description/skills
16
+ - `searchTeams(query)` — ranked team search by name/description/roles
17
+
18
+ #### P2: Team Onboarding Generator
19
+ - `buildTeamOnboarding()` generates markdown from run history
20
+ - Shows: past runs, stats, usage examples, available teams
21
+ - `loadRunSummaries()` helper for run history loading
22
+
23
+ #### P3: Task Explain Context
24
+ - `handleExplain(runId, taskId)` — full run or individual task explanation
25
+ - `buildTaskExplainContext()` — causal chain, layers, files produced
26
+ - `formatTaskExplain()` — markdown output with why/what/connections
27
+
28
+ #### P4: Unified Run Graph
29
+ - `buildRunGraph()` — consolidates manifest + tasks into single graph
30
+ - `saveRunGraph()` / `loadRunGraph()` — persist to `.crew/graphs/`
31
+ - `listRunGraphs()` — enumerate archived graphs
32
+
33
+ #### P5: Run Result Caching
34
+ - `computeRunCacheKey()` — SHA-256 hash of goal+team+workflow
35
+ - `getCachedRun()` / `saveRunToCache()` — TTL-based cache (default 1h)
36
+ - `clearCache()` / `getCacheStats()` — cache management
37
+
38
+ #### P6: Agent Checkpointing
39
+ - `FileCheckpointStore` — checkpoints in `.crew/state/runs/<runId>/checkpoints/`
40
+ - `saveCheckpoint()` / `loadCheckpoint()` / `clearCheckpoint()`
41
+ - `hasCheckpoint()` / `listCheckpoints()` for recovery
42
+
43
+ ### Tests
44
+ - 56 new unit tests (all passing)
45
+ - Total: 1796 unit tests + 45 integration tests passing
46
+
47
+ ### Bug Fixes
48
+ - Worktree test teardown: clean `.crew/` before git checks for clean repository
49
+
50
+ ---
51
+
3
52
  ## [0.4.0] — 9arm-skills Enforcement Patterns & Integration Tests (2026-05-26)
4
53
 
5
54
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -0,0 +1,176 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { projectCrewRoot } from "../utils/paths.ts";
4
+ import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
5
+ import type { TeamRunManifest } from "../state/types.ts";
6
+
7
+ export interface OnboardingOptions {
8
+ team?: string;
9
+ limit?: number;
10
+ cwd?: string;
11
+ }
12
+
13
+ interface RunSummary {
14
+ runId: string;
15
+ status: string;
16
+ goal: string;
17
+ team: string;
18
+ createdAt: string;
19
+ completedAt?: string;
20
+ taskCount: number;
21
+ }
22
+
23
+ /**
24
+ * Load run summaries from the state directory.
25
+ */
26
+ function loadRunSummaries(cwd: string, options: OnboardingOptions = {}): RunSummary[] {
27
+ const crewRoot = projectCrewRoot(cwd);
28
+ const runsRoot = path.join(crewRoot, "state", "runs");
29
+
30
+ if (!fs.existsSync(runsRoot)) return [];
31
+
32
+ const limit = options.limit ?? 5;
33
+ const teamFilter = options.team;
34
+
35
+ const entries = fs.readdirSync(runsRoot)
36
+ .filter((e) => {
37
+ try {
38
+ return fs.statSync(path.join(runsRoot, e)).isDirectory();
39
+ } catch {
40
+ return false;
41
+ }
42
+ })
43
+ .sort()
44
+ .reverse()
45
+ .slice(0, 100);
46
+
47
+ const summaries: RunSummary[] = [];
48
+
49
+ for (const runId of entries) {
50
+ if (summaries.length >= limit) break;
51
+
52
+ const manifestPath = path.join(runsRoot, runId, "manifest.json");
53
+ if (!fs.existsSync(manifestPath)) continue;
54
+
55
+ try {
56
+ const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as TeamRunManifest & { completedAt?: string };
57
+
58
+ if (teamFilter && raw.team !== teamFilter) continue;
59
+
60
+ summaries.push({
61
+ runId,
62
+ status: raw.status,
63
+ goal: raw.goal ?? "",
64
+ team: raw.team,
65
+ createdAt: raw.createdAt,
66
+ completedAt: raw.completedAt ?? raw.updatedAt,
67
+ taskCount: (raw as Record<string, unknown>).tasks != null
68
+ ? ((raw as Record<string, unknown>).tasks as unknown[]).length
69
+ : 0,
70
+ });
71
+ } catch {
72
+ continue;
73
+ }
74
+ }
75
+
76
+ return summaries;
77
+ }
78
+
79
+ /**
80
+ * Format duration in minutes.
81
+ */
82
+ function formatDuration(createdAt: string, completedAt?: string): string {
83
+ const start = new Date(createdAt).getTime();
84
+ const end = completedAt ? new Date(completedAt).getTime() : Date.now();
85
+ const minutes = Math.round((end - start) / 1000 / 60);
86
+ if (minutes < 1) return "<1m";
87
+ if (minutes >= 60) return `${Math.round(minutes / 60)}h`;
88
+ return `${minutes}m`;
89
+ }
90
+
91
+ /**
92
+ * Build markdown onboarding guide for a team.
93
+ */
94
+ export function buildTeamOnboarding(team: string, cwd: string, options: OnboardingOptions = {}): string {
95
+ const runs = loadRunSummaries(cwd, { ...options, team });
96
+
97
+ const lines: string[] = [];
98
+
99
+ // Header
100
+ lines.push(`# Team: ${team}`);
101
+ lines.push("");
102
+ lines.push(`> Multi-agent ${team} team for pi-crew.`);
103
+ lines.push("");
104
+
105
+ // Overview stats
106
+ const completed = runs.filter((r) => r.status === "completed");
107
+ const failed = runs.filter((r) => r.status === "failed" || r.status === "cancelled");
108
+
109
+ let avgDuration = 0;
110
+ if (completed.length > 0) {
111
+ const totalMs = completed.reduce((sum, r) => {
112
+ const start = new Date(r.createdAt).getTime();
113
+ const end = r.completedAt ? new Date(r.completedAt).getTime() : Date.now();
114
+ return sum + (end - start);
115
+ }, 0);
116
+ avgDuration = totalMs / completed.length / 1000 / 60;
117
+ }
118
+
119
+ lines.push("## Overview");
120
+ lines.push("");
121
+ lines.push(`| Metric | Value |`);
122
+ lines.push(`|--------|-------|`);
123
+ lines.push(`| Total runs | ${runs.length} |`);
124
+ lines.push(`| Completed | ${completed.length} |`);
125
+ lines.push(`| Failed/Cancelled | ${failed.length} |`);
126
+ if (avgDuration > 0) {
127
+ lines.push(`| Avg duration | ${avgDuration.toFixed(1)} min |`);
128
+ }
129
+ lines.push("");
130
+
131
+ // Past runs table
132
+ if (runs.length > 0) {
133
+ lines.push("## Past Runs");
134
+ lines.push("");
135
+ lines.push("| Run | Goal | Duration | Status |");
136
+ lines.push("|-----|------|----------|--------|");
137
+
138
+ for (const run of runs) {
139
+ const duration = formatDuration(run.createdAt, run.completedAt);
140
+ const goalPreview = run.goal ? run.goal.slice(0, 40) : "N/A";
141
+ const statusIcon = run.status === "completed" ? "✅" : run.status === "failed" ? "❌" : "⚠️";
142
+ lines.push(`| \`${run.runId.slice(-8)}\` | ${goalPreview}${run.goal.length > 40 ? "..." : ""} | ${duration} | ${statusIcon} ${run.status} |`);
143
+ }
144
+ lines.push("");
145
+ }
146
+
147
+ // How to run
148
+ lines.push("## How to Run");
149
+ lines.push("");
150
+ lines.push("```bash");
151
+ lines.push(`team action='run' team='${team}' goal="<your goal>"`);
152
+ lines.push("```");
153
+ lines.push("");
154
+ lines.push("See `team action='help'` for more options.");
155
+ lines.push("");
156
+
157
+ // Teams
158
+ lines.push("## Available Teams");
159
+ lines.push("");
160
+ try {
161
+ const discovery = discoverTeams(cwd);
162
+ const teams = allTeams(discovery);
163
+ for (const t of teams) {
164
+ lines.push(`- \`${t.name}\` — ${t.description ?? "No description"}`);
165
+ }
166
+ } catch {
167
+ lines.push("- *(team discovery unavailable)*");
168
+ }
169
+ lines.push("");
170
+
171
+ // Footer
172
+ lines.push("---");
173
+ lines.push("*Generated by pi-crew. For up-to-date info, check recent run history.*");
174
+
175
+ return lines.join("\n");
176
+ }
@@ -0,0 +1,268 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { toolResult } from "../tool-result.ts";
4
+ import { loadRunManifestById } from "../../state/state-store.ts";
5
+ import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
6
+
7
+ /**
8
+ * Local wrapper matching the planned `result` API used by handleExplain.
9
+ */
10
+ function result(text: string, details: Record<string, unknown>, isError: boolean): { isError: boolean; text: string } {
11
+ return { isError, text };
12
+ }
13
+
14
+ export interface TaskExplainContext {
15
+ taskId: string;
16
+ role: string;
17
+ status: string;
18
+ phase?: string;
19
+ why: string;
20
+ what: string;
21
+ filesTouched: string[];
22
+ connectedTasks: Array<{ taskId: string; status: string; relation: string }>;
23
+ layer: string;
24
+ complexity: "simple" | "moderate" | "complex";
25
+ usage?: { inputTokens?: number; outputTokens?: number };
26
+ duration?: number;
27
+ }
28
+
29
+ /**
30
+ * Build explain context for a specific task in a run.
31
+ */
32
+ export function buildTaskExplainContext(
33
+ manifest: TeamRunManifest,
34
+ tasks: TeamTaskState[],
35
+ taskId: string,
36
+ ): TaskExplainContext {
37
+ const task = tasks.find((t) => t.id === taskId);
38
+ if (!task) {
39
+ throw new Error(`Task ${taskId} not found in run ${manifest.runId}`);
40
+ }
41
+
42
+ const dependsOn = task.dependsOn ?? [];
43
+ const dependents = tasks.filter((t) => (t.dependsOn ?? []).includes(taskId));
44
+
45
+ // Layer from phase
46
+ const layerMap: Record<string, string> = {
47
+ explore: "exploration",
48
+ plan: "planning",
49
+ assess: "assessment",
50
+ execute: "execution",
51
+ verify: "verification",
52
+ analyze: "analysis",
53
+ write: "documentation",
54
+ "": "unknown",
55
+ };
56
+ const layer = layerMap[task.adaptive?.phase ?? ""] ?? "unknown";
57
+
58
+ // Why it exists
59
+ let why = `Part of ${manifest.team ?? "unknown"} team workflow.`;
60
+ if (dependsOn.length > 0) {
61
+ const depTasks = dependsOn.map((d) => {
62
+ const dep = tasks.find((t) => t.id === d);
63
+ return dep ? `\`${d}\` (${dep.status})` : `\`${d}\``;
64
+ }).join(", ");
65
+ why += ` Depends on ${depTasks}.`;
66
+ }
67
+ if (dependents.length > 0) {
68
+ why += ` ${dependents.length} task(s) depend on this.`;
69
+ }
70
+
71
+ // What it did
72
+ let what = `Ran agent: ${task.role}`;
73
+ if (task.model) {
74
+ what += ` (${task.model})`;
75
+ }
76
+ if (task.usage) {
77
+ const inputTokens = task.usage.input ?? 0;
78
+ const outputTokens = task.usage.output ?? 0;
79
+ what += `. Usage: input=${inputTokens}, output=${outputTokens}`;
80
+ }
81
+ if (task.status === "failed") {
82
+ what += `. Status: FAILED`;
83
+ if (task.error) {
84
+ what += ` — ${task.error}`;
85
+ }
86
+ }
87
+
88
+ // Files from artifacts
89
+ const artifactsPath = manifest.artifactsRoot;
90
+ const filesTouched: string[] = [];
91
+ if (fs.existsSync(artifactsPath)) {
92
+ try {
93
+ const entries = fs.readdirSync(artifactsPath);
94
+ for (const entry of entries) {
95
+ const fullPath = path.join(artifactsPath, entry);
96
+ try {
97
+ if (fs.statSync(fullPath).isFile()) {
98
+ filesTouched.push(entry);
99
+ }
100
+ } catch { /* ignore */ }
101
+ }
102
+ } catch { /* ignore */ }
103
+ }
104
+
105
+ // Duration
106
+ let duration: number | undefined;
107
+ if (task.startedAt && task.finishedAt) {
108
+ duration = (new Date(task.finishedAt).getTime() - new Date(task.startedAt).getTime()) / 1000;
109
+ }
110
+
111
+ // Complexity
112
+ const complexity = tasks.length <= 3 ? "simple" : tasks.length <= 8 ? "moderate" : "complex";
113
+
114
+ return {
115
+ taskId,
116
+ role: task.role,
117
+ status: task.status,
118
+ phase: task.adaptive?.phase,
119
+ why,
120
+ what,
121
+ filesTouched,
122
+ connectedTasks: [
123
+ ...dependsOn.map((d) => {
124
+ const dep = tasks.find((t) => t.id === d);
125
+ return {
126
+ taskId: d,
127
+ status: dep?.status ?? "unknown",
128
+ relation: "depends on",
129
+ };
130
+ }),
131
+ ...dependents.map((d) => ({
132
+ taskId: d.id,
133
+ status: d.status,
134
+ relation: "depended on by",
135
+ })),
136
+ ],
137
+ layer,
138
+ complexity,
139
+ usage: task.usage ? { inputTokens: task.usage.input, outputTokens: task.usage.output } : undefined,
140
+ duration,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Format task explain context as markdown.
146
+ */
147
+ export function formatTaskExplain(ctx: TaskExplainContext): string {
148
+ const lines: string[] = [];
149
+
150
+ lines.push(`# Task: ${ctx.taskId} (${ctx.role})`);
151
+ lines.push("");
152
+ lines.push(`| | |`);
153
+ lines.push(`|---|---|`);
154
+ lines.push(`| **Status** | ${ctx.status} |`);
155
+ if (ctx.phase) lines.push(`| **Phase** | ${ctx.phase} |`);
156
+ lines.push(`| **Layer** | ${ctx.layer} |`);
157
+ lines.push(`| **Complexity** | ${ctx.complexity} |`);
158
+
159
+ if (ctx.duration) {
160
+ const minutes = Math.round(ctx.duration / 60);
161
+ lines.push(`| **Duration** | ${minutes} min |`);
162
+ }
163
+
164
+ if (ctx.usage) {
165
+ const input = ctx.usage.inputTokens ?? 0;
166
+ const output = ctx.usage.outputTokens ?? 0;
167
+ lines.push(`| **Usage** | ↑${input} ↓${output} tokens |`);
168
+ }
169
+
170
+ lines.push("");
171
+ lines.push(`## Why it exists`);
172
+ lines.push("");
173
+ lines.push(ctx.why);
174
+ lines.push("");
175
+ lines.push(`## What it did`);
176
+ lines.push("");
177
+ lines.push(ctx.what);
178
+ lines.push("");
179
+
180
+ if (ctx.filesTouched.length > 0) {
181
+ lines.push("## Files produced");
182
+ lines.push("");
183
+ for (const file of ctx.filesTouched) {
184
+ lines.push(`- \`${file}\``);
185
+ }
186
+ lines.push("");
187
+ }
188
+
189
+ if (ctx.connectedTasks.length > 0) {
190
+ lines.push("## Connected tasks");
191
+ lines.push("");
192
+ for (const conn of ctx.connectedTasks) {
193
+ lines.push(`- ${conn.relation} \`${conn.taskId}\` (${conn.status})`);
194
+ }
195
+ lines.push("");
196
+ }
197
+
198
+ return lines.join("\n");
199
+ }
200
+
201
+ /**
202
+ * Handle team action='explain'.
203
+ */
204
+ export function handleExplain(params: {
205
+ runId?: string;
206
+ taskId?: string;
207
+ cwd?: string;
208
+ }, cwd: string): { isError: boolean; text: string } {
209
+ if (!params.runId) {
210
+ return result("explain requires runId", { action: "explain", status: "error" }, true);
211
+ }
212
+
213
+ const loaded = loadRunManifestById(cwd, params.runId);
214
+ if (!loaded) {
215
+ return result(`Run '${params.runId}' not found.`, { action: "explain", status: "error" }, true);
216
+ }
217
+
218
+ const { manifest, tasks } = loaded;
219
+
220
+ if (params.taskId) {
221
+ try {
222
+ const ctx = buildTaskExplainContext(manifest, tasks, params.taskId);
223
+ const output = formatTaskExplain(ctx);
224
+ return result(output, { action: "explain", runId: params.runId }, false);
225
+ } catch (err) {
226
+ return result(`Task '${params.taskId}' not found: ${err instanceof Error ? err.message : String(err)}`, { action: "explain", status: "error" }, true);
227
+ }
228
+ }
229
+
230
+ // Explain entire run
231
+ const lines: string[] = [];
232
+ lines.push(`# Run: ${params.runId}`);
233
+ lines.push("");
234
+ lines.push(`| | |`);
235
+ lines.push(`|---|---|`);
236
+
237
+ const start = new Date(manifest.createdAt).getTime();
238
+ const end = manifest.updatedAt ? new Date(manifest.updatedAt).getTime() : Date.now();
239
+ const duration = Math.round((end - start) / 1000 / 60);
240
+
241
+ lines.push(`| **Team** | ${manifest.team} |`);
242
+ lines.push(`| **Workflow** | ${manifest.workflow ?? "default"} |`);
243
+ lines.push(`| **Status** | ${manifest.status} |`);
244
+ lines.push(`| **Duration** | ${duration} min |`);
245
+ lines.push(`| **Tasks** | ${tasks.length} |`);
246
+ lines.push("");
247
+
248
+ lines.push("## Tasks");
249
+ lines.push("");
250
+ lines.push("| Task | Role | Status | Layer |");
251
+ lines.push("|------|------|--------|-------|");
252
+
253
+ for (const task of tasks) {
254
+ const layerMap: Record<string, string> = {
255
+ explore: "exploration", plan: "planning", assess: "assessment",
256
+ execute: "execution", verify: "verification", analyze: "analysis", write: "documentation",
257
+ };
258
+ const layer = layerMap[task.adaptive?.phase ?? ""] ?? "unknown";
259
+ const statusIcon = task.status === "completed" ? "✅" : task.status === "failed" ? "❌" : "⏳";
260
+ lines.push(`| \`${task.id}\` | ${task.role} | ${statusIcon} ${task.status} | ${layer} |`);
261
+ }
262
+ lines.push("");
263
+
264
+ lines.push("---");
265
+ lines.push(`*Use \`team action='explain' runId=${params.runId} taskId=<taskId>\` for task detail.*`);
266
+
267
+ return result(lines.join("\n"), { action: "explain", runId: params.runId }, false);
268
+ }
@@ -1,4 +1,5 @@
1
1
  import { allAgents, discoverAgents } from "../../agents/discover-agents.ts";
2
+ import { ensureCrewDirectory } from "../../state/crew-init.ts";
2
3
  import { allTeams, discoverTeams } from "../../teams/discover-teams.ts";
3
4
  import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workflows.ts";
4
5
  import { loadConfig } from "../../config/config.ts";
@@ -74,6 +75,9 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
74
75
  if (!goal) return result("Run requires goal or task.", { action: "run", status: "error" }, true);
75
76
  const intentPrefix = goal.length > 60 ? `${goal.slice(0, 57)}...` : goal;
76
77
 
78
+ // P0: Ensure .crew directory structure exists before creating any manifests.
79
+ await ensureCrewDirectory(ctx.cwd);
80
+
77
81
  const teams = allTeams(discoverTeams(ctx.cwd));
78
82
  const workflows = allWorkflows(discoverWorkflows(ctx.cwd));
79
83
  const agents = allAgents(discoverAgents(ctx.cwd));