pi-crew 0.1.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/AGENTS.md +32 -0
- package/CHANGELOG.md +6 -0
- package/LICENSE +21 -0
- package/NOTICE.md +15 -0
- package/README.md +703 -0
- package/agents/analyst.md +11 -0
- package/agents/critic.md +11 -0
- package/agents/executor.md +11 -0
- package/agents/explorer.md +11 -0
- package/agents/planner.md +11 -0
- package/agents/reviewer.md +11 -0
- package/agents/security-reviewer.md +11 -0
- package/agents/test-engineer.md +11 -0
- package/agents/verifier.md +11 -0
- package/agents/writer.md +11 -0
- package/docs/architecture.md +92 -0
- package/docs/live-mailbox-runtime.md +36 -0
- package/docs/publishing.md +65 -0
- package/docs/resource-formats.md +131 -0
- package/docs/usage.md +203 -0
- package/index.ts +6 -0
- package/install.mjs +19 -0
- package/package.json +79 -0
- package/schema.json +45 -0
- package/skills/.gitkeep +0 -0
- package/src/agents/agent-config.ts +27 -0
- package/src/agents/agent-serializer.ts +34 -0
- package/src/agents/discover-agents.ts +73 -0
- package/src/config/config.ts +193 -0
- package/src/extension/async-notifier.ts +36 -0
- package/src/extension/autonomous-policy.ts +122 -0
- package/src/extension/help.ts +43 -0
- package/src/extension/import-index.ts +52 -0
- package/src/extension/management.ts +335 -0
- package/src/extension/project-init.ts +74 -0
- package/src/extension/register.ts +349 -0
- package/src/extension/run-bundle-schema.ts +85 -0
- package/src/extension/run-export.ts +59 -0
- package/src/extension/run-import.ts +46 -0
- package/src/extension/run-index.ts +28 -0
- package/src/extension/run-maintenance.ts +24 -0
- package/src/extension/session-summary.ts +8 -0
- package/src/extension/team-manager-command.ts +86 -0
- package/src/extension/team-recommendation.ts +174 -0
- package/src/extension/team-tool.ts +783 -0
- package/src/extension/tool-result.ts +16 -0
- package/src/extension/validate-resources.ts +77 -0
- package/src/prompt/prompt-runtime.ts +58 -0
- package/src/runtime/async-runner.ts +26 -0
- package/src/runtime/background-runner.ts +43 -0
- package/src/runtime/child-pi.ts +75 -0
- package/src/runtime/model-fallback.ts +101 -0
- package/src/runtime/pi-args.ts +81 -0
- package/src/runtime/pi-json-output.ts +110 -0
- package/src/runtime/pi-spawn.ts +96 -0
- package/src/runtime/process-status.ts +25 -0
- package/src/runtime/task-runner.ts +164 -0
- package/src/runtime/team-runner.ts +135 -0
- package/src/runtime/worker-heartbeat.ts +21 -0
- package/src/schema/team-tool-schema.ts +100 -0
- package/src/state/artifact-store.ts +36 -0
- package/src/state/atomic-write.ts +18 -0
- package/src/state/contracts.ts +88 -0
- package/src/state/event-log.ts +27 -0
- package/src/state/locks.ts +40 -0
- package/src/state/mailbox.ts +188 -0
- package/src/state/state-store.ts +119 -0
- package/src/state/task-claims.ts +42 -0
- package/src/state/types.ts +88 -0
- package/src/state/usage.ts +29 -0
- package/src/teams/discover-teams.ts +84 -0
- package/src/teams/team-config.ts +22 -0
- package/src/teams/team-serializer.ts +36 -0
- package/src/ui/run-dashboard.ts +138 -0
- package/src/utils/frontmatter.ts +36 -0
- package/src/utils/ids.ts +12 -0
- package/src/utils/names.ts +26 -0
- package/src/utils/paths.ts +15 -0
- package/src/workflows/discover-workflows.ts +101 -0
- package/src/workflows/validate-workflow.ts +40 -0
- package/src/workflows/workflow-config.ts +24 -0
- package/src/workflows/workflow-serializer.ts +31 -0
- package/src/worktree/cleanup.ts +69 -0
- package/src/worktree/worktree-manager.ts +60 -0
- package/teams/default.team.md +12 -0
- package/teams/fast-fix.team.md +11 -0
- package/teams/implementation.team.md +15 -0
- package/teams/research.team.md +11 -0
- package/teams/review.team.md +12 -0
- package/tsconfig.json +19 -0
- package/workflows/default.workflow.md +29 -0
- package/workflows/fast-fix.workflow.md +22 -0
- package/workflows/implementation.workflow.md +47 -0
- package/workflows/research.workflow.md +22 -0
- package/workflows/review.workflow.md +30 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
3
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
4
|
+
|
|
5
|
+
export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "reload";
|
|
6
|
+
export interface RunDashboardSelection {
|
|
7
|
+
runId: string;
|
|
8
|
+
action: RunDashboardAction;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function truncate(value: string, width: number): string {
|
|
12
|
+
if (width <= 0) return "";
|
|
13
|
+
if (value.length <= width) return value;
|
|
14
|
+
if (width <= 1) return "…";
|
|
15
|
+
return `${value.slice(0, width - 1)}…`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function statusIcon(status: string): string {
|
|
19
|
+
if (status === "completed") return "✓";
|
|
20
|
+
if (status === "failed") return "✗";
|
|
21
|
+
if (status === "cancelled") return "!";
|
|
22
|
+
if (status === "running") return "▶";
|
|
23
|
+
if (status === "blocked") return "■";
|
|
24
|
+
return "·";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readProgressPreview(run: TeamRunManifest, maxLines = 5): string[] {
|
|
28
|
+
const progress = [...run.artifacts].reverse().find((artifact) => artifact.kind === "progress");
|
|
29
|
+
if (!progress || !fs.existsSync(progress.path)) return ["Progress: (none)"];
|
|
30
|
+
try {
|
|
31
|
+
return ["Progress:", ...fs.readFileSync(progress.path, "utf-8").split(/\r?\n/).filter(Boolean).slice(0, maxLines)];
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return [`Progress: failed to read (${message})`];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function countByStatus(runs: TeamRunManifest[]): string {
|
|
39
|
+
const counts = new Map<string, number>();
|
|
40
|
+
for (const run of runs) counts.set(run.status, (counts.get(run.status) ?? 0) + 1);
|
|
41
|
+
return [...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class RunDashboard implements Component {
|
|
45
|
+
private selected = 0;
|
|
46
|
+
private showFullProgress = false;
|
|
47
|
+
private readonly runs: TeamRunManifest[];
|
|
48
|
+
private readonly done: (selection: RunDashboardSelection | undefined) => void;
|
|
49
|
+
|
|
50
|
+
constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void) {
|
|
51
|
+
this.runs = runs;
|
|
52
|
+
this.done = done;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
invalidate(): void {}
|
|
56
|
+
|
|
57
|
+
render(width: number): string[] {
|
|
58
|
+
const innerWidth = Math.max(20, width - 4);
|
|
59
|
+
const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
|
|
60
|
+
const lines = [
|
|
61
|
+
`╭${"─".repeat(borderWidth)}╮`,
|
|
62
|
+
`│ ${truncate("pi-crew dashboard", innerWidth - 1).padEnd(innerWidth - 1)}│`,
|
|
63
|
+
`│ ${truncate("↑/↓/j/k select • r reload • p progress • s/u/a/i actions • q close", innerWidth - 1).padEnd(innerWidth - 1)}│`,
|
|
64
|
+
`│ ${truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs)}`, innerWidth - 1).padEnd(innerWidth - 1)}│`,
|
|
65
|
+
`├${"─".repeat(borderWidth)}┤`,
|
|
66
|
+
];
|
|
67
|
+
if (this.runs.length === 0) {
|
|
68
|
+
lines.push(`│ ${truncate("No runs found.", innerWidth - 1).padEnd(innerWidth - 1)}│`);
|
|
69
|
+
} else {
|
|
70
|
+
for (let i = 0; i < Math.min(this.runs.length, 10); i++) {
|
|
71
|
+
const run = this.runs[i]!;
|
|
72
|
+
const marker = i === this.selected ? "›" : " ";
|
|
73
|
+
const text = `${marker} ${statusIcon(run.status)} ${run.runId} ${run.status} ${run.team}/${run.workflow ?? "none"} ${run.goal}`;
|
|
74
|
+
lines.push(`│ ${truncate(text, innerWidth - 1).padEnd(innerWidth - 1)}│`);
|
|
75
|
+
}
|
|
76
|
+
const selectedRun = this.runs[this.selected];
|
|
77
|
+
if (selectedRun) {
|
|
78
|
+
lines.push(`├${"─".repeat(borderWidth)}┤`);
|
|
79
|
+
const details = [
|
|
80
|
+
`Selected: ${selectedRun.runId}`,
|
|
81
|
+
`Status: ${selectedRun.status} | Team: ${selectedRun.team} | Workflow: ${selectedRun.workflow ?? "none"}`,
|
|
82
|
+
`Created: ${selectedRun.createdAt}`,
|
|
83
|
+
`Updated: ${selectedRun.updatedAt}`,
|
|
84
|
+
`Artifacts: ${selectedRun.artifacts.length} | Workspace: ${selectedRun.workspaceMode}`,
|
|
85
|
+
selectedRun.async ? `Async: pid=${selectedRun.async.pid ?? "unknown"} log=${selectedRun.async.logPath}` : "Async: no",
|
|
86
|
+
`Goal: ${selectedRun.goal}`,
|
|
87
|
+
];
|
|
88
|
+
for (const detail of [...details, ...readProgressPreview(selectedRun, this.showFullProgress ? 20 : 5)]) {
|
|
89
|
+
lines.push(`│ ${truncate(detail, innerWidth - 1).padEnd(innerWidth - 1)}│`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
lines.push(`╰${"─".repeat(borderWidth)}╯`);
|
|
94
|
+
return lines.map((line) => truncate(line, width));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
handleInput(data: string): void {
|
|
98
|
+
if (data === "q" || data === "\u001b") {
|
|
99
|
+
this.done(undefined);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (data === "\r" || data === "\n" || data === "s") {
|
|
103
|
+
const runId = this.runs[this.selected]?.runId;
|
|
104
|
+
this.done(runId ? { runId, action: "status" } : undefined);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (data === "u") {
|
|
108
|
+
const runId = this.runs[this.selected]?.runId;
|
|
109
|
+
this.done(runId ? { runId, action: "summary" } : undefined);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (data === "a") {
|
|
113
|
+
const runId = this.runs[this.selected]?.runId;
|
|
114
|
+
this.done(runId ? { runId, action: "artifacts" } : undefined);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (data === "i") {
|
|
118
|
+
const runId = this.runs[this.selected]?.runId;
|
|
119
|
+
this.done(runId ? { runId, action: "api" } : undefined);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (data === "r") {
|
|
123
|
+
this.done({ runId: this.runs[this.selected]?.runId ?? "", action: "reload" });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (data === "p") {
|
|
127
|
+
this.showFullProgress = !this.showFullProgress;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (data === "k" || data === "\u001b[A") {
|
|
131
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (data === "j" || data === "\u001b[B") {
|
|
135
|
+
this.selected = Math.min(Math.max(0, this.runs.length - 1), this.selected + 1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface ParsedFrontmatter {
|
|
2
|
+
frontmatter: Record<string, string>;
|
|
3
|
+
body: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function parseFrontmatter(content: string): ParsedFrontmatter {
|
|
7
|
+
if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
|
|
8
|
+
return { frontmatter: {}, body: content };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const normalized = content.replaceAll("\r\n", "\n");
|
|
12
|
+
const end = normalized.indexOf("\n---\n", 4);
|
|
13
|
+
if (end === -1) return { frontmatter: {}, body: content };
|
|
14
|
+
|
|
15
|
+
const raw = normalized.slice(4, end);
|
|
16
|
+
const body = normalized.slice(end + "\n---\n".length);
|
|
17
|
+
const frontmatter: Record<string, string> = {};
|
|
18
|
+
|
|
19
|
+
for (const line of raw.split("\n")) {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
22
|
+
const separator = trimmed.indexOf(":");
|
|
23
|
+
if (separator === -1) continue;
|
|
24
|
+
const key = trimmed.slice(0, separator).trim();
|
|
25
|
+
const value = trimmed.slice(separator + 1).trim();
|
|
26
|
+
if (key) frontmatter[key] = value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { frontmatter, body };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseCsv(value: string | undefined): string[] | undefined {
|
|
33
|
+
if (value === undefined) return undefined;
|
|
34
|
+
const values = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
35
|
+
return values.length > 0 ? [...new Set(values)] : undefined;
|
|
36
|
+
}
|
package/src/utils/ids.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function createRunId(prefix = "team"): string {
|
|
4
|
+
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
|
|
5
|
+
const suffix = randomBytes(4).toString("hex");
|
|
6
|
+
return `${prefix}_${stamp}_${suffix}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createTaskId(stepId: string, index: number): string {
|
|
10
|
+
const normalized = stepId.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
|
|
11
|
+
return `${String(index + 1).padStart(2, "0")}_${normalized}`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function sanitizeName(name: string): string {
|
|
2
|
+
return name.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function requireString(value: unknown, label: string): { value?: string; error?: string } {
|
|
6
|
+
if (typeof value !== "string" || !value.trim()) return { error: `${label} must be a non-empty string.` };
|
|
7
|
+
return { value: value.trim() };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseConfigObject(config: unknown): { value?: Record<string, unknown>; error?: string } {
|
|
11
|
+
let parsed = config;
|
|
12
|
+
if (typeof parsed === "string") {
|
|
13
|
+
try {
|
|
14
|
+
parsed = JSON.parse(parsed) as unknown;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
return { error: `config must be valid JSON: ${message}` };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { error: "config must be an object." };
|
|
21
|
+
return { value: parsed as Record<string, unknown> };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hasOwn(obj: Record<string, unknown>, key: string): boolean {
|
|
25
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export function packageRoot(): string {
|
|
6
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function userPiRoot(): string {
|
|
10
|
+
return path.join(os.homedir(), ".pi", "agent");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function projectPiRoot(cwd: string): string {
|
|
14
|
+
return path.join(cwd, ".pi");
|
|
15
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ResourceSource } from "../agents/agent-config.ts";
|
|
4
|
+
import { parseCsv, parseFrontmatter } from "../utils/frontmatter.ts";
|
|
5
|
+
import { packageRoot, projectPiRoot, userPiRoot } from "../utils/paths.ts";
|
|
6
|
+
import type { WorkflowConfig, WorkflowStep } from "./workflow-config.ts";
|
|
7
|
+
|
|
8
|
+
export interface WorkflowDiscoveryResult {
|
|
9
|
+
builtin: WorkflowConfig[];
|
|
10
|
+
user: WorkflowConfig[];
|
|
11
|
+
project: WorkflowConfig[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseStepSection(id: string, body: string): WorkflowStep | undefined {
|
|
15
|
+
const lines = body.trim().split("\n");
|
|
16
|
+
const config: Record<string, string> = {};
|
|
17
|
+
const taskLines: string[] = [];
|
|
18
|
+
let inTask = false;
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
if (!inTask) {
|
|
21
|
+
if (line.trim() === "") {
|
|
22
|
+
inTask = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
|
26
|
+
if (match) {
|
|
27
|
+
config[match[1]!.trim()] = match[2]!.trim();
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
inTask = true;
|
|
31
|
+
}
|
|
32
|
+
taskLines.push(line);
|
|
33
|
+
}
|
|
34
|
+
const role = config.role || id;
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
role,
|
|
38
|
+
task: taskLines.join("\n").trim() || config.task || "{goal}",
|
|
39
|
+
dependsOn: parseCsv(config.dependsOn),
|
|
40
|
+
parallelGroup: config.parallelGroup || undefined,
|
|
41
|
+
output: config.output === "false" ? false : config.output || undefined,
|
|
42
|
+
reads: config.reads === "false" ? false : parseCsv(config.reads),
|
|
43
|
+
model: config.model || undefined,
|
|
44
|
+
skills: config.skills === "false" ? false : parseCsv(config.skills),
|
|
45
|
+
progress: config.progress === "true" ? true : config.progress === "false" ? false : undefined,
|
|
46
|
+
worktree: config.worktree === "true" ? true : config.worktree === "false" ? false : undefined,
|
|
47
|
+
verify: config.verify === "true" ? true : config.verify === "false" ? false : undefined,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseWorkflowFile(filePath: string, source: ResourceSource): WorkflowConfig | undefined {
|
|
52
|
+
try {
|
|
53
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
54
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
55
|
+
const name = frontmatter.name?.trim() || path.basename(filePath, ".workflow.md");
|
|
56
|
+
const matches = [...body.matchAll(/^##\s+(.+)[^\S\n]*$/gm)];
|
|
57
|
+
const steps: WorkflowStep[] = [];
|
|
58
|
+
for (let i = 0; i < matches.length; i++) {
|
|
59
|
+
const match = matches[i]!;
|
|
60
|
+
const id = match[1]!.trim();
|
|
61
|
+
const sectionStart = match.index! + match[0].length + (body[match.index! + match[0].length] === "\n" ? 1 : 0);
|
|
62
|
+
const sectionEnd = i + 1 < matches.length ? matches[i + 1]!.index! : body.length;
|
|
63
|
+
const step = parseStepSection(id, body.slice(sectionStart, sectionEnd));
|
|
64
|
+
if (step) steps.push(step);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
name,
|
|
68
|
+
description: frontmatter.description?.trim() || "No description provided.",
|
|
69
|
+
source,
|
|
70
|
+
filePath,
|
|
71
|
+
steps,
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readWorkflowDir(dir: string, source: ResourceSource): WorkflowConfig[] {
|
|
79
|
+
if (!fs.existsSync(dir)) return [];
|
|
80
|
+
return fs.readdirSync(dir)
|
|
81
|
+
.filter((entry) => entry.endsWith(".workflow.md"))
|
|
82
|
+
.map((entry) => parseWorkflowFile(path.join(dir, entry), source))
|
|
83
|
+
.filter((workflow): workflow is WorkflowConfig => workflow !== undefined)
|
|
84
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function discoverWorkflows(cwd: string): WorkflowDiscoveryResult {
|
|
88
|
+
return {
|
|
89
|
+
builtin: readWorkflowDir(path.join(packageRoot(), "workflows"), "builtin"),
|
|
90
|
+
user: readWorkflowDir(path.join(userPiRoot(), "workflows"), "user"),
|
|
91
|
+
project: readWorkflowDir(path.join(projectPiRoot(cwd), "workflows"), "project"),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function allWorkflows(discovery: WorkflowDiscoveryResult): WorkflowConfig[] {
|
|
96
|
+
const byName = new Map<string, WorkflowConfig>();
|
|
97
|
+
for (const workflow of [...discovery.builtin, ...discovery.user, ...discovery.project]) {
|
|
98
|
+
byName.set(workflow.name, workflow);
|
|
99
|
+
}
|
|
100
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
101
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { TeamConfig } from "../teams/team-config.ts";
|
|
2
|
+
import type { WorkflowConfig } from "./workflow-config.ts";
|
|
3
|
+
|
|
4
|
+
export function validateWorkflowForTeam(workflow: WorkflowConfig, team: TeamConfig): string[] {
|
|
5
|
+
const errors: string[] = [];
|
|
6
|
+
const roles = new Set(team.roles.map((role) => role.name));
|
|
7
|
+
const stepIds = new Set<string>();
|
|
8
|
+
|
|
9
|
+
for (const step of workflow.steps) {
|
|
10
|
+
if (stepIds.has(step.id)) errors.push(`Duplicate workflow step id '${step.id}'.`);
|
|
11
|
+
stepIds.add(step.id);
|
|
12
|
+
if (!roles.has(step.role)) errors.push(`Step '${step.id}' references unknown team role '${step.role}'.`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const step of workflow.steps) {
|
|
16
|
+
for (const dep of step.dependsOn ?? []) {
|
|
17
|
+
if (!stepIds.has(dep)) errors.push(`Step '${step.id}' depends on unknown step '${dep}'.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const visiting = new Set<string>();
|
|
22
|
+
const visited = new Set<string>();
|
|
23
|
+
const byId = new Map(workflow.steps.map((step) => [step.id, step]));
|
|
24
|
+
|
|
25
|
+
function visit(id: string, trail: string[]): void {
|
|
26
|
+
if (visited.has(id)) return;
|
|
27
|
+
if (visiting.has(id)) {
|
|
28
|
+
errors.push(`Workflow dependency cycle detected: ${[...trail, id].join(" -> ")}.`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
visiting.add(id);
|
|
32
|
+
const step = byId.get(id);
|
|
33
|
+
for (const dep of step?.dependsOn ?? []) visit(dep, [...trail, id]);
|
|
34
|
+
visiting.delete(id);
|
|
35
|
+
visited.add(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const step of workflow.steps) visit(step.id, []);
|
|
39
|
+
return [...new Set(errors)];
|
|
40
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ResourceSource } from "../agents/agent-config.ts";
|
|
2
|
+
|
|
3
|
+
export interface WorkflowStep {
|
|
4
|
+
id: string;
|
|
5
|
+
role: string;
|
|
6
|
+
task: string;
|
|
7
|
+
dependsOn?: string[];
|
|
8
|
+
parallelGroup?: string;
|
|
9
|
+
output?: string | false;
|
|
10
|
+
reads?: string[] | false;
|
|
11
|
+
model?: string;
|
|
12
|
+
skills?: string[] | false;
|
|
13
|
+
progress?: boolean;
|
|
14
|
+
worktree?: boolean;
|
|
15
|
+
verify?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WorkflowConfig {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
source: ResourceSource;
|
|
22
|
+
filePath: string;
|
|
23
|
+
steps: WorkflowStep[];
|
|
24
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { WorkflowConfig, WorkflowStep } from "./workflow-config.ts";
|
|
2
|
+
|
|
3
|
+
function serializeStep(step: WorkflowStep): string[] {
|
|
4
|
+
const lines = [`## ${step.id}`, `role: ${step.role}`];
|
|
5
|
+
if (step.dependsOn?.length) lines.push(`dependsOn: ${step.dependsOn.join(", ")}`);
|
|
6
|
+
if (step.parallelGroup) lines.push(`parallelGroup: ${step.parallelGroup}`);
|
|
7
|
+
if (step.output === false) lines.push("output: false");
|
|
8
|
+
else if (step.output) lines.push(`output: ${step.output}`);
|
|
9
|
+
if (step.reads === false) lines.push("reads: false");
|
|
10
|
+
else if (Array.isArray(step.reads) && step.reads.length > 0) lines.push(`reads: ${step.reads.join(", ")}`);
|
|
11
|
+
if (step.model) lines.push(`model: ${step.model}`);
|
|
12
|
+
if (step.skills === false) lines.push("skills: false");
|
|
13
|
+
else if (Array.isArray(step.skills) && step.skills.length > 0) lines.push(`skills: ${step.skills.join(", ")}`);
|
|
14
|
+
if (step.progress !== undefined) lines.push(`progress: ${step.progress ? "true" : "false"}`);
|
|
15
|
+
if (step.worktree !== undefined) lines.push(`worktree: ${step.worktree ? "true" : "false"}`);
|
|
16
|
+
if (step.verify !== undefined) lines.push(`verify: ${step.verify ? "true" : "false"}`);
|
|
17
|
+
lines.push("", step.task.trim(), "");
|
|
18
|
+
return lines;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function serializeWorkflow(workflow: WorkflowConfig): string {
|
|
22
|
+
const lines = [
|
|
23
|
+
"---",
|
|
24
|
+
`name: ${workflow.name}`,
|
|
25
|
+
`description: ${workflow.description}`,
|
|
26
|
+
"---",
|
|
27
|
+
"",
|
|
28
|
+
...workflow.steps.flatMap(serializeStep),
|
|
29
|
+
];
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
5
|
+
import { writeArtifact } from "../state/artifact-store.ts";
|
|
6
|
+
|
|
7
|
+
export interface WorktreeCleanupResult {
|
|
8
|
+
removed: string[];
|
|
9
|
+
preserved: Array<{ path: string; reason: string }>;
|
|
10
|
+
artifactPaths: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function git(cwd: string, args: string[]): string {
|
|
14
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isDirty(worktreePath: string): boolean {
|
|
18
|
+
try {
|
|
19
|
+
return git(worktreePath, ["status", "--porcelain"]).trim().length > 0;
|
|
20
|
+
} catch {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function captureDiff(worktreePath: string): string {
|
|
26
|
+
try {
|
|
27
|
+
return [git(worktreePath, ["status", "--porcelain"]), "", git(worktreePath, ["diff", "--stat"]), "", git(worktreePath, ["diff"])].join("\n");
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
return `Failed to capture cleanup diff for ${worktreePath}: ${message}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?: boolean } = {}): WorktreeCleanupResult {
|
|
35
|
+
const worktreeRoot = path.join(manifest.cwd, ".pi", "teams", "worktrees", manifest.runId);
|
|
36
|
+
const result: WorktreeCleanupResult = { removed: [], preserved: [], artifactPaths: [] };
|
|
37
|
+
if (!fs.existsSync(worktreeRoot)) return result;
|
|
38
|
+
|
|
39
|
+
for (const entry of fs.readdirSync(worktreeRoot)) {
|
|
40
|
+
const worktreePath = path.join(worktreeRoot, entry);
|
|
41
|
+
if (!fs.statSync(worktreePath).isDirectory()) continue;
|
|
42
|
+
const dirty = isDirty(worktreePath);
|
|
43
|
+
if (dirty && !options.force) {
|
|
44
|
+
const artifact = writeArtifact(manifest.artifactsRoot, {
|
|
45
|
+
kind: "diff",
|
|
46
|
+
relativePath: `cleanup/${entry}.diff`,
|
|
47
|
+
content: captureDiff(worktreePath),
|
|
48
|
+
producer: "worktree-cleanup",
|
|
49
|
+
});
|
|
50
|
+
result.artifactPaths.push(artifact.path);
|
|
51
|
+
result.preserved.push({ path: worktreePath, reason: "dirty worktree preserved" });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
git(manifest.cwd, ["worktree", "remove", options.force ? "--force" : "", worktreePath].filter(Boolean));
|
|
56
|
+
result.removed.push(worktreePath);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
59
|
+
result.preserved.push({ path: worktreePath, reason: message });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
if (fs.existsSync(worktreeRoot) && fs.readdirSync(worktreeRoot).length === 0) fs.rmSync(worktreeRoot, { recursive: true, force: true });
|
|
65
|
+
} catch {
|
|
66
|
+
// Non-critical cleanup.
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { loadConfig } from "../config/config.ts";
|
|
5
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
6
|
+
|
|
7
|
+
export interface PreparedTaskWorkspace {
|
|
8
|
+
cwd: string;
|
|
9
|
+
worktreePath?: string;
|
|
10
|
+
branch?: string;
|
|
11
|
+
reused?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function git(cwd: string, args: string[]): string {
|
|
15
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sanitizeBranchPart(value: string): string {
|
|
19
|
+
return value.toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function findGitRoot(cwd: string): string {
|
|
23
|
+
return git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function assertCleanLeader(repoRoot: string): void {
|
|
27
|
+
const status = git(repoRoot, ["status", "--porcelain"]);
|
|
28
|
+
if (status.trim()) {
|
|
29
|
+
throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskState): PreparedTaskWorkspace {
|
|
34
|
+
if (manifest.workspaceMode !== "worktree") return { cwd: task.cwd };
|
|
35
|
+
const repoRoot = findGitRoot(manifest.cwd);
|
|
36
|
+
const loadedConfig = loadConfig(manifest.cwd);
|
|
37
|
+
if (loadedConfig.config.requireCleanWorktreeLeader !== false) assertCleanLeader(repoRoot);
|
|
38
|
+
const worktreeRoot = path.join(repoRoot, ".pi", "teams", "worktrees", manifest.runId);
|
|
39
|
+
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
40
|
+
const worktreePath = path.join(worktreeRoot, task.id);
|
|
41
|
+
const branch = `pi-crew/${sanitizeBranchPart(manifest.runId)}/${sanitizeBranchPart(task.id)}`;
|
|
42
|
+
if (fs.existsSync(worktreePath)) {
|
|
43
|
+
const currentBranch = git(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
44
|
+
if (currentBranch !== branch) {
|
|
45
|
+
throw new Error(`Existing worktree branch mismatch at ${worktreePath}: expected '${branch}', got '${currentBranch}'.`);
|
|
46
|
+
}
|
|
47
|
+
return { cwd: worktreePath, worktreePath, branch, reused: true };
|
|
48
|
+
}
|
|
49
|
+
git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
|
|
50
|
+
return { cwd: worktreePath, worktreePath, branch, reused: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function captureWorktreeDiff(worktreePath: string): string {
|
|
54
|
+
try {
|
|
55
|
+
return git(worktreePath, ["diff", "--stat"]) + "\n\n" + git(worktreePath, ["diff"]);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
return `Failed to capture worktree diff: ${message}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: default
|
|
3
|
+
description: Balanced team for ordinary implementation tasks
|
|
4
|
+
defaultWorkflow: default
|
|
5
|
+
workspaceMode: single
|
|
6
|
+
maxConcurrency: 2
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
- explorer: agent=explorer fast discovery
|
|
10
|
+
- planner: agent=planner plan the work
|
|
11
|
+
- executor: agent=executor implement changes
|
|
12
|
+
- verifier: agent=verifier verify completion
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fast-fix
|
|
3
|
+
description: Small team for quick bug fixes
|
|
4
|
+
defaultWorkflow: fast-fix
|
|
5
|
+
workspaceMode: single
|
|
6
|
+
maxConcurrency: 1
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
- explorer: agent=explorer find the relevant files
|
|
10
|
+
- executor: agent=executor make the fix
|
|
11
|
+
- verifier: agent=verifier verify the fix
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: implementation
|
|
3
|
+
description: Full implementation team with analysis, critique, execution, review, and verification
|
|
4
|
+
defaultWorkflow: implementation
|
|
5
|
+
workspaceMode: single
|
|
6
|
+
maxConcurrency: 3
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
- explorer: agent=explorer map the codebase
|
|
10
|
+
- analyst: agent=analyst clarify requirements and constraints
|
|
11
|
+
- planner: agent=planner create execution plan
|
|
12
|
+
- critic: agent=critic challenge the plan
|
|
13
|
+
- executor: agent=executor implement the plan
|
|
14
|
+
- reviewer: agent=reviewer review the implementation
|
|
15
|
+
- verifier: agent=verifier verify done
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: research
|
|
3
|
+
description: Team for investigation and documentation
|
|
4
|
+
defaultWorkflow: research
|
|
5
|
+
workspaceMode: single
|
|
6
|
+
maxConcurrency: 2
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
- explorer: agent=explorer gather codebase facts
|
|
10
|
+
- analyst: agent=analyst analyze findings
|
|
11
|
+
- writer: agent=writer produce final notes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: review
|
|
3
|
+
description: Team for code review and security review
|
|
4
|
+
defaultWorkflow: review
|
|
5
|
+
workspaceMode: single
|
|
6
|
+
maxConcurrency: 2
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
- explorer: agent=explorer understand changed areas
|
|
10
|
+
- reviewer: agent=reviewer review correctness and maintainability
|
|
11
|
+
- security-reviewer: agent=security-reviewer review security risks
|
|
12
|
+
- verifier: agent=verifier summarize pass/fail
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noImplicitAny": true,
|
|
8
|
+
"exactOptionalPropertyTypes": false,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": [
|
|
15
|
+
"*.ts",
|
|
16
|
+
"src/**/*.ts",
|
|
17
|
+
"src/**/*.ts"
|
|
18
|
+
]
|
|
19
|
+
}
|