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.
- package/CHANGELOG.md +72 -0
- package/package.json +1 -1
- package/skills/REFERENCE.md +136 -0
- package/skills/delegation-patterns/SKILL.md +1 -1
- package/skills/event-log-tracing/SKILL.md +1 -1
- package/skills/multi-perspective-review/SKILL.md +17 -1
- package/skills/orchestration/SKILL.md +1 -1
- package/skills/post-mortem/SKILL.md +90 -0
- package/skills/safe-bash/SKILL.md +1 -1
- package/skills/scrutinize/SKILL.md +67 -0
- package/skills/systematic-debugging/SKILL.md +60 -5
- package/skills/verification-before-done/SKILL.md +1 -1
- package/skills/workspace-isolation/SKILL.md +1 -1
- package/src/extension/team-onboard.ts +176 -0
- package/src/extension/team-tool/explain.ts +268 -0
- package/src/extension/team-tool/run.ts +4 -0
- package/src/runtime/checkpoint.ts +232 -0
- package/src/state/crew-init.ts +121 -0
- package/src/state/gitignore-manager.ts +51 -0
- package/src/state/run-cache.ts +176 -0
- package/src/state/run-graph.ts +199 -0
- package/src/utils/bm25-search.ts +209 -0
- package/test-integration-check.ts +114 -0
|
@@ -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));
|
|
@@ -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
|
+
}
|