pi-baton 0.2.2

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,191 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import { MissingAgentsError, validateWorkflowAgents } from "../lib/agents.ts";
5
+ import { ensureBatonScaffolding } from "../lib/paths.ts";
6
+ import { formatRunResultSummary, runContinuous } from "../lib/run-engine.ts";
7
+ import { createRunUiController } from "../lib/run-ui.ts";
8
+ import { ActiveRunGuardError, createIdleRun, loadActiveRun } from "../lib/run-store.ts";
9
+ import { NO_ACTIVE_RUN_MESSAGE, formatStatusSummary } from "../lib/status.ts";
10
+ import { createSubagentRunner } from "../lib/subagent-runner.ts";
11
+ import { WorkflowNameCollisionError, createWorkflowScaffold } from "../lib/workflow-scaffold.ts";
12
+ import { WorkflowValidationError } from "../lib/workflow-schema.ts";
13
+ import { discoverWorkflowItems, loadWorkflowById } from "../lib/workflow-discovery.ts";
14
+
15
+ function requireUi(ctx: ExtensionCommandContext): boolean {
16
+ if (ctx.hasUI) return true;
17
+ ctx.ui.notify("Pi Baton commands require interactive UI.", "warning");
18
+ return false;
19
+ }
20
+
21
+ async function openWorkflowEditor(ctx: ExtensionCommandContext, filePath: string): Promise<void> {
22
+ const content = await readFile(filePath, "utf8");
23
+ const edited = await ctx.ui.editor(`Edit workflow: ${basename(filePath)}`, content);
24
+ if (edited !== undefined && edited !== content) {
25
+ await writeFile(filePath, edited, "utf8");
26
+ }
27
+ }
28
+
29
+ export default function (pi: ExtensionAPI) {
30
+ pi.registerCommand("baton:new", {
31
+ description: "Create a derived workflow scaffold from default-review-loop",
32
+ handler: async (_args, ctx) => {
33
+ if (!requireUi(ctx)) return;
34
+
35
+ try {
36
+ await ensureBatonScaffolding(ctx.cwd);
37
+
38
+ const displayName = await ctx.ui.input("Workflow name:", "My Review Loop");
39
+ if (displayName === undefined) return;
40
+
41
+ const trimmed = displayName.trim();
42
+ if (!trimmed) {
43
+ ctx.ui.notify("Workflow name is required.", "warning");
44
+ return;
45
+ }
46
+
47
+ const { filePath } = await createWorkflowScaffold(ctx.cwd, trimmed);
48
+ await openWorkflowEditor(ctx, filePath);
49
+ ctx.ui.notify(`Created workflow scaffold: ${filePath}`, "info");
50
+ } catch (error) {
51
+ if (error instanceof WorkflowNameCollisionError) {
52
+ ctx.ui.notify(`${error.message}. Choose a different name.`, "warning");
53
+ return;
54
+ }
55
+
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ ctx.ui.notify(`Failed to create workflow scaffold: ${message}`, "error");
58
+ }
59
+ },
60
+ });
61
+
62
+ pi.registerCommand("baton:start", {
63
+ description: "Create an idle Baton run from a workflow and task brief",
64
+ handler: async (_args, ctx) => {
65
+ if (!requireUi(ctx)) return;
66
+
67
+ try {
68
+ await ensureBatonScaffolding(ctx.cwd);
69
+
70
+ const workflows = await discoverWorkflowItems(ctx.cwd);
71
+ if (workflows.length === 0) {
72
+ ctx.ui.notify("No workflows found. Run /baton:new first.", "warning");
73
+ return;
74
+ }
75
+
76
+ const labels = workflows.map((workflow) => workflow.name);
77
+ const selectedName = await ctx.ui.select("Choose workflow:", labels);
78
+ if (!selectedName) return;
79
+
80
+ const workflowItem = workflows.find((workflow) => workflow.name === selectedName);
81
+ if (!workflowItem) return;
82
+
83
+ const workflow = await loadWorkflowById(ctx.cwd, workflowItem.id);
84
+ validateWorkflowAgents(ctx.cwd, workflow);
85
+
86
+ const taskBrief = await ctx.ui.input("Task brief:", "");
87
+ if (taskBrief === undefined) return;
88
+
89
+ const trimmedBrief = taskBrief.trim();
90
+ if (!trimmedBrief) {
91
+ ctx.ui.notify("Task brief is required.", "warning");
92
+ return;
93
+ }
94
+
95
+ const manifest = await createIdleRun(ctx.cwd, {
96
+ workflowId: workflow.id,
97
+ workflowName: workflow.name,
98
+ workflowPath: workflow.path,
99
+ workflowSource: workflow.source,
100
+ taskBrief: trimmedBrief,
101
+ targetDirectory: ctx.cwd,
102
+ entryStep: workflow.entryStep,
103
+ iterationCap: workflow.iteration_cap,
104
+ });
105
+
106
+ ctx.ui.notify(
107
+ `Idle run created (${manifest.id}). Run /baton:run when ready.`,
108
+ "info",
109
+ );
110
+ } catch (error) {
111
+ if (error instanceof ActiveRunGuardError) {
112
+ ctx.ui.notify(error.message, "warning");
113
+ return;
114
+ }
115
+ if (error instanceof WorkflowValidationError) {
116
+ ctx.ui.notify(`Workflow validation failed: ${error.message}`, "error");
117
+ return;
118
+ }
119
+ if (error instanceof MissingAgentsError) {
120
+ ctx.ui.notify(error.message, "error");
121
+ return;
122
+ }
123
+
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ ctx.ui.notify(`Failed to start Baton run: ${message}`, "error");
126
+ }
127
+ },
128
+ });
129
+
130
+ pi.registerCommand("baton:run", {
131
+ description: "Run the active idle Baton run to a terminal state",
132
+ handler: async (_args, ctx) => {
133
+ try {
134
+ const manifest = await loadActiveRun(ctx.cwd);
135
+ if (!manifest) {
136
+ ctx.ui.notify(NO_ACTIVE_RUN_MESSAGE, "info");
137
+ return;
138
+ }
139
+
140
+ if (manifest.state !== "idle" && manifest.state !== "running") {
141
+ ctx.ui.notify(`Run ${manifest.id} is already terminal (${manifest.state}).`, "warning");
142
+ return;
143
+ }
144
+
145
+ const workflow = await loadWorkflowById(ctx.cwd, manifest.workflowId);
146
+ validateWorkflowAgents(ctx.cwd, workflow);
147
+
148
+ const runUi = createRunUiController(ctx);
149
+
150
+ try {
151
+ const summary = await runContinuous({
152
+ cwd: ctx.cwd,
153
+ runId: manifest.id,
154
+ stepRunner: createSubagentRunner(),
155
+ sessionModel: ctx.model,
156
+ onProgress: runUi.onProgress,
157
+ });
158
+
159
+ ctx.ui.notify(formatRunResultSummary(summary), summary.state === "completed" ? "info" : "error");
160
+ } finally {
161
+ runUi.clear();
162
+ }
163
+ } catch (error) {
164
+ if (error instanceof WorkflowValidationError) {
165
+ ctx.ui.notify(`Workflow validation failed: ${error.message}`, "error");
166
+ return;
167
+ }
168
+ if (error instanceof MissingAgentsError) {
169
+ ctx.ui.notify(error.message, "error");
170
+ return;
171
+ }
172
+
173
+ const message = error instanceof Error ? error.message : String(error);
174
+ ctx.ui.notify(`Baton run failed: ${message}`, "error");
175
+ }
176
+ },
177
+ });
178
+
179
+ pi.registerCommand("baton:status", {
180
+ description: "Show the active Baton run summary",
181
+ handler: async (_args, ctx) => {
182
+ const manifest = await loadActiveRun(ctx.cwd);
183
+ if (!manifest) {
184
+ ctx.ui.notify(NO_ACTIVE_RUN_MESSAGE, "info");
185
+ return;
186
+ }
187
+
188
+ ctx.ui.notify(formatStatusSummary(manifest), "info");
189
+ },
190
+ });
191
+ }
package/lib/agents.ts ADDED
@@ -0,0 +1,133 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
4
+ import { getPackageAgentsDir } from "./paths.ts";
5
+ import type { WorkflowDefinition } from "./types.ts";
6
+
7
+ export type AgentScope = "user" | "project" | "both";
8
+ export type AgentSource = "user" | "project" | "builtin";
9
+
10
+ export interface AgentConfig {
11
+ name: string;
12
+ description: string;
13
+ tools?: string[];
14
+ model?: string;
15
+ systemPrompt: string;
16
+ source: AgentSource;
17
+ filePath: string;
18
+ }
19
+
20
+ export class MissingAgentsError extends Error {
21
+ readonly missing: string[];
22
+ readonly available: string[];
23
+
24
+ constructor(missing: string[], available: string[]) {
25
+ const availableText = available.length > 0 ? available.join(", ") : "none";
26
+ super(
27
+ `Missing Pi subagents: ${missing.join(", ")}. Available agents: ${availableText}. ` +
28
+ "Add agents under .pi/agents/ or use the pi-baton builtin worker/reviewer agents.",
29
+ );
30
+ this.name = "MissingAgentsError";
31
+ this.missing = missing;
32
+ this.available = available;
33
+ }
34
+ }
35
+
36
+ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
37
+ const agents: AgentConfig[] = [];
38
+ if (!fs.existsSync(dir)) return agents;
39
+
40
+ let entries: fs.Dirent[];
41
+ try {
42
+ entries = fs.readdirSync(dir, { withFileTypes: true });
43
+ } catch {
44
+ return agents;
45
+ }
46
+
47
+ for (const entry of entries) {
48
+ if (!entry.name.endsWith(".md")) continue;
49
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
50
+
51
+ const filePath = path.join(dir, entry.name);
52
+ let content: string;
53
+ try {
54
+ content = fs.readFileSync(filePath, "utf-8");
55
+ } catch {
56
+ continue;
57
+ }
58
+
59
+ const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
60
+ if (!frontmatter.name || !frontmatter.description) continue;
61
+
62
+ const tools = frontmatter.tools
63
+ ?.split(",")
64
+ .map((tool) => tool.trim())
65
+ .filter(Boolean);
66
+
67
+ agents.push({
68
+ name: frontmatter.name,
69
+ description: frontmatter.description,
70
+ tools: tools && tools.length > 0 ? tools : undefined,
71
+ model: frontmatter.model,
72
+ systemPrompt: body,
73
+ source,
74
+ filePath,
75
+ });
76
+ }
77
+
78
+ return agents;
79
+ }
80
+
81
+ function findNearestProjectAgentsDir(cwd: string): string | null {
82
+ let currentDir = cwd;
83
+ while (true) {
84
+ const candidate = path.join(currentDir, ".pi", "agents");
85
+ try {
86
+ if (fs.statSync(candidate).isDirectory()) return candidate;
87
+ } catch {
88
+ // keep walking
89
+ }
90
+
91
+ const parentDir = path.dirname(currentDir);
92
+ if (parentDir === currentDir) return null;
93
+ currentDir = parentDir;
94
+ }
95
+ }
96
+
97
+ export function discoverAgents(cwd: string, scope: AgentScope = "both"): AgentConfig[] {
98
+ const userDir = path.join(getAgentDir(), "agents");
99
+ const projectAgentsDir = findNearestProjectAgentsDir(cwd);
100
+ const builtinDir = getPackageAgentsDir();
101
+
102
+ const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
103
+ const projectAgents =
104
+ scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
105
+ const builtinAgents = loadAgentsFromDir(builtinDir, "builtin");
106
+
107
+ const agentMap = new Map<string, AgentConfig>();
108
+
109
+ for (const agent of builtinAgents) agentMap.set(agent.name, agent);
110
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
111
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
112
+
113
+ return Array.from(agentMap.values());
114
+ }
115
+
116
+ export function findAgent(cwd: string, agentName: string): AgentConfig | undefined {
117
+ return discoverAgents(cwd, "both").find((agent) => agent.name === agentName);
118
+ }
119
+
120
+ export function listRequiredAgents(workflow: WorkflowDefinition): string[] {
121
+ return [...new Set(Object.values(workflow.steps).map((step) => step.agent))].sort();
122
+ }
123
+
124
+ export function validateWorkflowAgents(cwd: string, workflow: WorkflowDefinition): void {
125
+ const required = listRequiredAgents(workflow);
126
+ const available = discoverAgents(cwd, "both");
127
+ const availableNames = available.map((agent) => agent.name);
128
+ const missing = required.filter((name) => !availableNames.includes(name));
129
+
130
+ if (missing.length > 0) {
131
+ throw new MissingAgentsError(missing, availableNames);
132
+ }
133
+ }
package/lib/handoff.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { RunManifest, StepHandoffPayload, StructuredStepEnvelope } from "./types.ts";
2
+
3
+ export function buildRunMetadata(manifest: RunManifest): StepHandoffPayload["runMetadata"] {
4
+ return {
5
+ runId: manifest.id,
6
+ workflowName: manifest.workflowName,
7
+ currentIteration: manifest.iteration,
8
+ targetDirectory: manifest.targetDirectory,
9
+ };
10
+ }
11
+
12
+ export function buildImplementHandoff(manifest: RunManifest): StepHandoffPayload {
13
+ return {
14
+ taskBrief: manifest.taskBrief,
15
+ runMetadata: buildRunMetadata(manifest),
16
+ };
17
+ }
18
+
19
+ export function buildLinearHandoff(
20
+ manifest: RunManifest,
21
+ previousEnvelope: StructuredStepEnvelope,
22
+ ): StepHandoffPayload {
23
+ return {
24
+ taskBrief: manifest.taskBrief,
25
+ stepOutputSummary: previousEnvelope.summary,
26
+ rawOutputPath: previousEnvelope.rawOutputPath,
27
+ runMetadata: buildRunMetadata(manifest),
28
+ };
29
+ }
30
+
31
+ export function buildFixHandoff(
32
+ manifest: RunManifest,
33
+ previousEnvelope: StructuredStepEnvelope,
34
+ reviewFindings: string[],
35
+ ): StepHandoffPayload {
36
+ return {
37
+ taskBrief: manifest.taskBrief,
38
+ reviewFindings,
39
+ previousOutputSummary: previousEnvelope.summary,
40
+ rawOutputPath: previousEnvelope.rawOutputPath,
41
+ runMetadata: buildRunMetadata(manifest),
42
+ };
43
+ }
44
+
45
+ export function formatHandoffForPrompt(handoff: StepHandoffPayload): string {
46
+ const lines = [
47
+ "## Task brief",
48
+ handoff.taskBrief,
49
+ "",
50
+ "## Run metadata",
51
+ `- run id: ${handoff.runMetadata.runId}`,
52
+ `- workflow: ${handoff.runMetadata.workflowName}`,
53
+ `- iteration: ${handoff.runMetadata.currentIteration}`,
54
+ `- target directory: ${handoff.runMetadata.targetDirectory}`,
55
+ ];
56
+
57
+ if (handoff.stepOutputSummary) {
58
+ lines.push("", "## Previous step summary", handoff.stepOutputSummary);
59
+ }
60
+
61
+ if (handoff.previousOutputSummary) {
62
+ lines.push("", "## Previous output summary", handoff.previousOutputSummary);
63
+ }
64
+
65
+ if (handoff.rawOutputPath) {
66
+ lines.push("", "## Raw output path", handoff.rawOutputPath);
67
+ }
68
+
69
+ if (handoff.reviewFindings?.length) {
70
+ lines.push("", "## Review findings");
71
+ for (const finding of handoff.reviewFindings) {
72
+ lines.push(`- ${finding}`);
73
+ }
74
+ }
75
+
76
+ return lines.join("\n");
77
+ }
78
+
79
+ export function buildStepPrompt(stepPrompt: string, handoff: StepHandoffPayload): string {
80
+ return `${stepPrompt.trim()}\n\n---\n\n${formatHandoffForPrompt(handoff)}`;
81
+ }
@@ -0,0 +1,8 @@
1
+ export function toKebabCase(input: string): string {
2
+ return input
3
+ .trim()
4
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
5
+ .replace(/[^a-zA-Z0-9]+/g, "-")
6
+ .replace(/^-+|-+$/g, "")
7
+ .toLowerCase();
8
+ }
@@ -0,0 +1,14 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import { isModelPlaceholder } from "./workflow-scaffold.ts";
3
+
4
+ export function resolveStepModel(
5
+ stepModel: string | undefined,
6
+ sessionModel: Model<any> | undefined,
7
+ ): string | undefined {
8
+ if (stepModel && !isModelPlaceholder(stepModel)) {
9
+ return stepModel;
10
+ }
11
+
12
+ if (!sessionModel) return undefined;
13
+ return `${sessionModel.provider}/${sessionModel.id}`;
14
+ }
package/lib/paths.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
6
+
7
+ export function getBatonRoot(cwd: string): string {
8
+ return join(cwd, ".pi", "baton");
9
+ }
10
+
11
+ export function getWorkflowsDir(cwd: string): string {
12
+ return join(getBatonRoot(cwd), "workflows");
13
+ }
14
+
15
+ export function getRunsDir(cwd: string): string {
16
+ return join(getBatonRoot(cwd), "runs");
17
+ }
18
+
19
+ export function getActiveRunPointerPath(cwd: string): string {
20
+ return join(getBatonRoot(cwd), "active-run.json");
21
+ }
22
+
23
+ export function getPackageWorkflowsDir(): string {
24
+ return join(PACKAGE_ROOT, "workflows");
25
+ }
26
+
27
+ export function getPackageAgentsDir(): string {
28
+ return join(PACKAGE_ROOT, "agents");
29
+ }
30
+
31
+ export async function ensureBatonScaffolding(cwd: string): Promise<void> {
32
+ await mkdir(getWorkflowsDir(cwd), { recursive: true });
33
+ await mkdir(getRunsDir(cwd), { recursive: true });
34
+ }
35
+
36
+ export function getRunDir(cwd: string, runId: string): string {
37
+ return join(getRunsDir(cwd), runId);
38
+ }
39
+
40
+ export function getRunManifestPath(cwd: string, runId: string): string {
41
+ return join(getRunDir(cwd, runId), "run.json");
42
+ }
43
+
44
+ export function getRunStepsDir(cwd: string, runId: string): string {
45
+ return join(getRunDir(cwd, runId), "steps");
46
+ }
47
+
48
+ export function getRunOutputsDir(cwd: string, runId: string): string {
49
+ return join(getRunDir(cwd, runId), "outputs");
50
+ }
51
+
52
+ export async function ensureRunDirs(cwd: string, runId: string): Promise<void> {
53
+ await mkdir(getRunStepsDir(cwd, runId), { recursive: true });
54
+ await mkdir(getRunOutputsDir(cwd, runId), { recursive: true });
55
+ }
56
+
57
+ export function workflowFilePath(cwd: string, filename: string): string {
58
+ return join(getWorkflowsDir(cwd), filename);
59
+ }
60
+
61
+ export async function ensureParentDir(filePath: string): Promise<void> {
62
+ await mkdir(dirname(filePath), { recursive: true });
63
+ }
@@ -0,0 +1,85 @@
1
+ import type { ReviewJudgment, StructuredStepEnvelope } from "./types.ts";
2
+
3
+ export class ReviewContractError extends Error {
4
+ constructor(message: string) {
5
+ super(message);
6
+ this.name = "ReviewContractError";
7
+ }
8
+ }
9
+
10
+ interface ParsedJsonBlock {
11
+ summary?: string;
12
+ judgment?: ReviewJudgment;
13
+ findings?: unknown;
14
+ acceptanceNote?: string;
15
+ }
16
+
17
+ function extractJsonBlock(text: string): ParsedJsonBlock | undefined {
18
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
19
+ const candidate = fenced?.[1]?.trim() ?? text.trim();
20
+
21
+ const start = candidate.lastIndexOf("{");
22
+ const end = candidate.lastIndexOf("}");
23
+ if (start === -1 || end === -1 || end <= start) return undefined;
24
+
25
+ try {
26
+ return JSON.parse(candidate.slice(start, end + 1)) as ParsedJsonBlock;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ function fallbackSummary(text: string): string {
33
+ const withoutFence = text.replace(/```[\s\S]*?```/g, "").trim();
34
+ const firstParagraph = withoutFence.split(/\n\s*\n/)[0]?.trim();
35
+ if (!firstParagraph) return "Step completed";
36
+ return firstParagraph.length > 500 ? `${firstParagraph.slice(0, 497)}...` : firstParagraph;
37
+ }
38
+
39
+ function normalizeFindings(value: unknown): string[] {
40
+ if (!Array.isArray(value)) return [];
41
+ return value
42
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
43
+ .filter((item) => item.length > 0);
44
+ }
45
+
46
+ export function parseStepEnvelope(
47
+ outputText: string,
48
+ rawOutputPath: string,
49
+ options: { isReviewStep: boolean },
50
+ ): StructuredStepEnvelope {
51
+ const parsed = extractJsonBlock(outputText);
52
+ const summary = parsed?.summary?.trim() || fallbackSummary(outputText);
53
+
54
+ const envelope: StructuredStepEnvelope = {
55
+ summary,
56
+ rawOutputPath,
57
+ };
58
+
59
+ if (!options.isReviewStep) {
60
+ return envelope;
61
+ }
62
+
63
+ const judgment = parsed?.judgment;
64
+ if (judgment !== "accept" && judgment !== "reject") {
65
+ throw new ReviewContractError('Review step must return judgment "accept" or "reject"');
66
+ }
67
+
68
+ envelope.judgment = judgment;
69
+
70
+ if (judgment === "reject") {
71
+ const findings = normalizeFindings(parsed?.findings);
72
+ if (findings.length === 0) {
73
+ throw new ReviewContractError("Review reject must include non-empty findings");
74
+ }
75
+ envelope.findings = findings;
76
+ return envelope;
77
+ }
78
+
79
+ const acceptanceNote = parsed?.acceptanceNote?.trim();
80
+ if (!acceptanceNote) {
81
+ throw new ReviewContractError("Review accept must include an acceptanceNote");
82
+ }
83
+ envelope.acceptanceNote = acceptanceNote;
84
+ return envelope;
85
+ }