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.
- package/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/agents/reviewer.md +15 -0
- package/agents/worker.md +12 -0
- package/docs/examples.md +49 -0
- package/docs/github-template.md +63 -0
- package/docs/release.md +57 -0
- package/docs/repository-settings.md +43 -0
- package/docs/template-checklist.md +140 -0
- package/docs/typescript.md +77 -0
- package/extensions/index.ts +191 -0
- package/lib/agents.ts +133 -0
- package/lib/handoff.ts +81 -0
- package/lib/kebab-case.ts +8 -0
- package/lib/model-routing.ts +14 -0
- package/lib/paths.ts +63 -0
- package/lib/review-contract.ts +85 -0
- package/lib/run-engine.ts +283 -0
- package/lib/run-store.ts +136 -0
- package/lib/run-ui.ts +51 -0
- package/lib/run-widget.ts +110 -0
- package/lib/schema.ts +8 -0
- package/lib/status.ts +15 -0
- package/lib/subagent-runner.ts +181 -0
- package/lib/types.ts +117 -0
- package/lib/workflow-discovery.ts +62 -0
- package/lib/workflow-scaffold.ts +92 -0
- package/lib/workflow-schema.ts +154 -0
- package/package.json +63 -0
- package/workflows/default-review-loop.yaml +49 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
6
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { findAgent } from "./agents.ts";
|
|
8
|
+
import type { StepExecutionRequest, StepExecutionResult, StepRunner } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
11
|
+
const currentScript = process.argv[1];
|
|
12
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
13
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
14
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
18
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
19
|
+
if (!isGenericRuntime) {
|
|
20
|
+
return { command: process.execPath, args };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { command: "pi", args };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getFinalOutput(messages: Message[]): string {
|
|
27
|
+
const assistantTexts = messages
|
|
28
|
+
.filter((message): message is Extract<Message, { role: "assistant" }> => message.role === "assistant")
|
|
29
|
+
.flatMap((message) =>
|
|
30
|
+
message.content
|
|
31
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
32
|
+
.map((block) => block.text),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return assistantTexts.join("\n").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
39
|
+
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-baton-"));
|
|
40
|
+
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
41
|
+
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
|
42
|
+
await withFileMutationQueue(filePath, async () => {
|
|
43
|
+
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
44
|
+
});
|
|
45
|
+
return { dir: tmpDir, filePath };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createSubagentRunner(): StepRunner {
|
|
49
|
+
return async (request: StepExecutionRequest): Promise<StepExecutionResult> => {
|
|
50
|
+
const agent = findAgent(request.cwd, request.agent);
|
|
51
|
+
if (!agent) {
|
|
52
|
+
return {
|
|
53
|
+
exitCode: 1,
|
|
54
|
+
outputText: "",
|
|
55
|
+
stderr: `Unknown agent: "${request.agent}"`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
60
|
+
|
|
61
|
+
const model = request.model ?? agent.model;
|
|
62
|
+
if (model) {
|
|
63
|
+
args.push("--model", model);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (agent.tools?.length) {
|
|
67
|
+
args.push("--tools", agent.tools.join(","));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let tmpPromptDir: string | null = null;
|
|
71
|
+
let tmpPromptPath: string | null = null;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
if (agent.systemPrompt.trim()) {
|
|
75
|
+
const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
76
|
+
tmpPromptDir = tmp.dir;
|
|
77
|
+
tmpPromptPath = tmp.filePath;
|
|
78
|
+
args.push("--append-system-prompt", tmpPromptPath);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
args.push(request.prompt);
|
|
82
|
+
|
|
83
|
+
let stderr = "";
|
|
84
|
+
const messages: Message[] = [];
|
|
85
|
+
let resolvedModel: string | undefined = model;
|
|
86
|
+
|
|
87
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
88
|
+
const invocation = getPiInvocation(args);
|
|
89
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
90
|
+
cwd: request.cwd,
|
|
91
|
+
shell: false,
|
|
92
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
let buffer = "";
|
|
96
|
+
|
|
97
|
+
const processLine = (line: string) => {
|
|
98
|
+
if (!line.trim()) return;
|
|
99
|
+
let event: { type?: string; message?: Message };
|
|
100
|
+
try {
|
|
101
|
+
event = JSON.parse(line) as { type?: string; message?: Message };
|
|
102
|
+
} catch {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (event.type === "message_end" && event.message) {
|
|
107
|
+
messages.push(event.message);
|
|
108
|
+
if (event.message.role === "assistant" && "model" in event.message && event.message.model) {
|
|
109
|
+
resolvedModel = String(event.message.model);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
proc.stdout.on("data", (data) => {
|
|
115
|
+
buffer += data.toString();
|
|
116
|
+
const lines = buffer.split("\n");
|
|
117
|
+
buffer = lines.pop() || "";
|
|
118
|
+
for (const line of lines) processLine(line);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
proc.stderr.on("data", (data) => {
|
|
122
|
+
stderr += data.toString();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
proc.on("close", (code) => {
|
|
126
|
+
if (buffer.trim()) processLine(buffer);
|
|
127
|
+
resolve(code ?? 0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
proc.on("error", () => resolve(1));
|
|
131
|
+
|
|
132
|
+
if (request.signal) {
|
|
133
|
+
const kill = () => {
|
|
134
|
+
proc.kill("SIGTERM");
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
137
|
+
}, 5000);
|
|
138
|
+
};
|
|
139
|
+
if (request.signal.aborted) kill();
|
|
140
|
+
else request.signal.addEventListener("abort", kill, { once: true });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
exitCode,
|
|
146
|
+
outputText: getFinalOutput(messages),
|
|
147
|
+
stderr,
|
|
148
|
+
model: resolvedModel,
|
|
149
|
+
};
|
|
150
|
+
} finally {
|
|
151
|
+
if (tmpPromptPath) {
|
|
152
|
+
try {
|
|
153
|
+
fs.unlinkSync(tmpPromptPath);
|
|
154
|
+
} catch {
|
|
155
|
+
// ignore
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (tmpPromptDir) {
|
|
159
|
+
try {
|
|
160
|
+
fs.rmdirSync(tmpPromptDir);
|
|
161
|
+
} catch {
|
|
162
|
+
// ignore
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createEchoStepRunner(): StepRunner {
|
|
170
|
+
return async (request) => ({
|
|
171
|
+
exitCode: 0,
|
|
172
|
+
outputText: request.prompt,
|
|
173
|
+
stderr: "",
|
|
174
|
+
model: request.model,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function shortenHome(filePath: string): string {
|
|
179
|
+
const home = os.homedir();
|
|
180
|
+
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
|
|
181
|
+
}
|
package/lib/types.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export type RunState = "idle" | "running" | "completed" | "failed";
|
|
2
|
+
|
|
3
|
+
export type ReviewJudgment = "accept" | "reject";
|
|
4
|
+
|
|
5
|
+
export interface WorkflowLinearStep {
|
|
6
|
+
kind: "linear";
|
|
7
|
+
name: string;
|
|
8
|
+
agent: string;
|
|
9
|
+
prompt: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
next: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WorkflowReviewStep {
|
|
15
|
+
kind: "review";
|
|
16
|
+
name: string;
|
|
17
|
+
agent: string;
|
|
18
|
+
prompt: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
on_accept: string;
|
|
21
|
+
on_reject: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type WorkflowStep = WorkflowLinearStep | WorkflowReviewStep;
|
|
25
|
+
|
|
26
|
+
export interface WorkflowDefinition {
|
|
27
|
+
id: string;
|
|
28
|
+
source: "user" | "builtin";
|
|
29
|
+
path: string;
|
|
30
|
+
name: string;
|
|
31
|
+
iteration_cap: number;
|
|
32
|
+
steps: Record<string, WorkflowStep>;
|
|
33
|
+
entryStep: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WorkflowListItem {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
source: "user" | "builtin";
|
|
40
|
+
path: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RunMetadata {
|
|
44
|
+
runId: string;
|
|
45
|
+
workflowName: string;
|
|
46
|
+
currentIteration: number;
|
|
47
|
+
targetDirectory: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface StepHandoffPayload {
|
|
51
|
+
taskBrief: string;
|
|
52
|
+
stepOutputSummary?: string;
|
|
53
|
+
rawOutputPath?: string;
|
|
54
|
+
runMetadata: RunMetadata;
|
|
55
|
+
reviewFindings?: string[];
|
|
56
|
+
previousOutputSummary?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StructuredStepEnvelope {
|
|
60
|
+
summary: string;
|
|
61
|
+
rawOutputPath: string;
|
|
62
|
+
judgment?: ReviewJudgment;
|
|
63
|
+
findings?: string[];
|
|
64
|
+
acceptanceNote?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface StepRecord {
|
|
68
|
+
stepName: string;
|
|
69
|
+
iteration: number;
|
|
70
|
+
agent: string;
|
|
71
|
+
model?: string;
|
|
72
|
+
startedAt: string;
|
|
73
|
+
finishedAt: string;
|
|
74
|
+
envelope: StructuredStepEnvelope;
|
|
75
|
+
exitCode: number;
|
|
76
|
+
error?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RunManifest {
|
|
80
|
+
id: string;
|
|
81
|
+
state: RunState;
|
|
82
|
+
workflowId: string;
|
|
83
|
+
workflowName: string;
|
|
84
|
+
workflowPath: string;
|
|
85
|
+
workflowSource: "user" | "builtin";
|
|
86
|
+
taskBrief: string;
|
|
87
|
+
targetDirectory: string;
|
|
88
|
+
entryStep: string;
|
|
89
|
+
currentStep: string | null;
|
|
90
|
+
lastStep: string | null;
|
|
91
|
+
iteration: number;
|
|
92
|
+
iterationCap: number;
|
|
93
|
+
createdAt: string;
|
|
94
|
+
updatedAt: string;
|
|
95
|
+
failureReason?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ActiveRunPointer {
|
|
99
|
+
runId: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface StepExecutionRequest {
|
|
103
|
+
agent: string;
|
|
104
|
+
prompt: string;
|
|
105
|
+
model?: string;
|
|
106
|
+
cwd: string;
|
|
107
|
+
signal?: AbortSignal;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface StepExecutionResult {
|
|
111
|
+
exitCode: number;
|
|
112
|
+
outputText: string;
|
|
113
|
+
stderr: string;
|
|
114
|
+
model?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type StepRunner = (request: StepExecutionRequest) => Promise<StepExecutionResult>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { getPackageWorkflowsDir, getWorkflowsDir } from "./paths.ts";
|
|
4
|
+
import type { WorkflowDefinition, WorkflowListItem } from "./types.ts";
|
|
5
|
+
import { parseWorkflowDocument } from "./workflow-schema.ts";
|
|
6
|
+
|
|
7
|
+
async function listYamlFiles(dir: string): Promise<string[]> {
|
|
8
|
+
try {
|
|
9
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
10
|
+
return entries
|
|
11
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".yaml"))
|
|
12
|
+
.map((entry) => join(dir, entry.name));
|
|
13
|
+
} catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function discoverWorkflowItems(cwd: string): Promise<WorkflowListItem[]> {
|
|
19
|
+
const userFiles = await listYamlFiles(getWorkflowsDir(cwd));
|
|
20
|
+
const builtinFiles = await listYamlFiles(getPackageWorkflowsDir());
|
|
21
|
+
|
|
22
|
+
const userItems: WorkflowListItem[] = [];
|
|
23
|
+
for (const filePath of userFiles) {
|
|
24
|
+
const yamlText = await readFile(filePath, "utf8");
|
|
25
|
+
const id = basename(filePath, ".yaml");
|
|
26
|
+
const workflow = parseWorkflowDocument(yamlText, { id, source: "user", path: filePath });
|
|
27
|
+
userItems.push({ id, name: workflow.name, source: "user", path: filePath });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const builtinItems: WorkflowListItem[] = [];
|
|
31
|
+
for (const filePath of builtinFiles) {
|
|
32
|
+
const yamlText = await readFile(filePath, "utf8");
|
|
33
|
+
const id = basename(filePath, ".yaml");
|
|
34
|
+
const workflow = parseWorkflowDocument(yamlText, { id, source: "builtin", path: filePath });
|
|
35
|
+
builtinItems.push({ id, name: workflow.name, source: "builtin", path: filePath });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return [...userItems, ...builtinItems];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function loadWorkflowById(cwd: string, workflowId: string): Promise<WorkflowDefinition> {
|
|
42
|
+
const items = await discoverWorkflowItems(cwd);
|
|
43
|
+
const match = items.find((item) => item.id === workflowId);
|
|
44
|
+
if (!match) {
|
|
45
|
+
throw new Error(`Unknown workflow: ${workflowId}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const yamlText = await readFile(match.path, "utf8");
|
|
49
|
+
return parseWorkflowDocument(yamlText, {
|
|
50
|
+
id: match.id,
|
|
51
|
+
source: match.source,
|
|
52
|
+
path: match.path,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function loadWorkflowFromPath(
|
|
57
|
+
filePath: string,
|
|
58
|
+
options: { id: string; source: "user" | "builtin" },
|
|
59
|
+
): Promise<WorkflowDefinition> {
|
|
60
|
+
const yamlText = await readFile(filePath, "utf8");
|
|
61
|
+
return parseWorkflowDocument(yamlText, { ...options, path: filePath });
|
|
62
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { toKebabCase } from "./kebab-case.ts";
|
|
4
|
+
import { ensureParentDir, getPackageWorkflowsDir, workflowFilePath } from "./paths.ts";
|
|
5
|
+
|
|
6
|
+
const FAST_MODEL_PLACEHOLDER = "<your-fast-model>";
|
|
7
|
+
const STRONG_MODEL_PLACEHOLDER = "<your-strong-model>";
|
|
8
|
+
|
|
9
|
+
export class WorkflowNameCollisionError extends Error {
|
|
10
|
+
readonly filename: string;
|
|
11
|
+
|
|
12
|
+
constructor(filename: string) {
|
|
13
|
+
super(`Workflow filename already exists: ${filename}`);
|
|
14
|
+
this.name = "WorkflowNameCollisionError";
|
|
15
|
+
this.filename = filename;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function deriveWorkflowFilename(displayName: string): string {
|
|
20
|
+
const filename = `${toKebabCase(displayName)}.yaml`;
|
|
21
|
+
if (!filename || filename === ".yaml") {
|
|
22
|
+
throw new Error("Workflow name must contain letters or numbers");
|
|
23
|
+
}
|
|
24
|
+
return filename;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildDerivedScaffoldYaml(builtinYaml: string): string {
|
|
28
|
+
const lines = builtinYaml.split(/\r?\n/);
|
|
29
|
+
const output: string[] = [];
|
|
30
|
+
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
if (line.trim().startsWith("name:")) {
|
|
33
|
+
output.push(line);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (/^\s{2}implement:\s*$/.test(line) || /^\s{2}fix:\s*$/.test(line)) {
|
|
38
|
+
output.push(line);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (/^\s{4}agent:\s+worker\s*$/.test(line)) {
|
|
43
|
+
output.push(line);
|
|
44
|
+
const indent = line.match(/^(\s+)/)?.[1] ?? " ";
|
|
45
|
+
output.push(`${indent}model: ${FAST_MODEL_PLACEHOLDER}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (/^\s{4}agent:\s+reviewer\s*$/.test(line)) {
|
|
50
|
+
output.push(line);
|
|
51
|
+
const indent = line.match(/^(\s+)/)?.[1] ?? " ";
|
|
52
|
+
output.push(`${indent}model: ${STRONG_MODEL_PLACEHOLDER}`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
output.push(line);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return output.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function createWorkflowScaffold(
|
|
63
|
+
cwd: string,
|
|
64
|
+
displayName: string,
|
|
65
|
+
): Promise<{ filePath: string; filename: string; content: string }> {
|
|
66
|
+
const filename = deriveWorkflowFilename(displayName);
|
|
67
|
+
const filePath = workflowFilePath(cwd, filename);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await access(filePath);
|
|
71
|
+
throw new WorkflowNameCollisionError(filename);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error instanceof WorkflowNameCollisionError) throw error;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const builtinPath = join(getPackageWorkflowsDir(), "default-review-loop.yaml");
|
|
77
|
+
const builtinYaml = await readFile(builtinPath, "utf8");
|
|
78
|
+
let content = buildDerivedScaffoldYaml(builtinYaml);
|
|
79
|
+
|
|
80
|
+
content = content.replace(/^name:.*$/m, `name: ${displayName.trim()}`);
|
|
81
|
+
|
|
82
|
+
await ensureParentDir(filePath);
|
|
83
|
+
await writeFile(filePath, content, "utf8");
|
|
84
|
+
|
|
85
|
+
return { filePath, filename, content };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isModelPlaceholder(model: string | undefined): boolean {
|
|
89
|
+
if (!model) return false;
|
|
90
|
+
const trimmed = model.trim();
|
|
91
|
+
return trimmed.startsWith("<") && trimmed.endsWith(">");
|
|
92
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
import type { WorkflowDefinition, WorkflowReviewStep, WorkflowStep } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export class WorkflowValidationError extends Error {
|
|
5
|
+
constructor(message: string) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "WorkflowValidationError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const COMPLETE_TOKEN = "_complete";
|
|
12
|
+
|
|
13
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireString(value: unknown, field: string): string {
|
|
18
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
19
|
+
throw new WorkflowValidationError(`${field} must be a non-empty string`);
|
|
20
|
+
}
|
|
21
|
+
return value.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function optionalString(value: unknown): string | undefined {
|
|
25
|
+
if (value === undefined || value === null) return undefined;
|
|
26
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
27
|
+
throw new WorkflowValidationError("model must be a non-empty string when provided");
|
|
28
|
+
}
|
|
29
|
+
return value.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseStep(name: string, raw: unknown): WorkflowStep {
|
|
33
|
+
if (!isRecord(raw)) {
|
|
34
|
+
throw new WorkflowValidationError(`steps.${name} must be an object`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const agent = requireString(raw.agent, `steps.${name}.agent`);
|
|
38
|
+
const prompt = requireString(raw.prompt, `steps.${name}.prompt`);
|
|
39
|
+
const model = optionalString(raw.model);
|
|
40
|
+
const hasNext = raw.next !== undefined;
|
|
41
|
+
const hasAccept = raw.on_accept !== undefined;
|
|
42
|
+
const hasReject = raw.on_reject !== undefined;
|
|
43
|
+
|
|
44
|
+
if (hasNext && (hasAccept || hasReject)) {
|
|
45
|
+
throw new WorkflowValidationError(`steps.${name} cannot mix next with review branches`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!hasNext && !(hasAccept && hasReject)) {
|
|
49
|
+
throw new WorkflowValidationError(
|
|
50
|
+
`steps.${name} must define next or both on_accept and on_reject`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (hasNext) {
|
|
55
|
+
return {
|
|
56
|
+
kind: "linear",
|
|
57
|
+
name,
|
|
58
|
+
agent,
|
|
59
|
+
prompt,
|
|
60
|
+
model,
|
|
61
|
+
next: requireString(raw.next, `steps.${name}.next`),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
kind: "review",
|
|
67
|
+
name,
|
|
68
|
+
agent,
|
|
69
|
+
prompt,
|
|
70
|
+
model,
|
|
71
|
+
on_accept: requireString(raw.on_accept, `steps.${name}.on_accept`),
|
|
72
|
+
on_reject: requireString(raw.on_reject, `steps.${name}.on_reject`),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseWorkflowDocument(
|
|
77
|
+
yamlText: string,
|
|
78
|
+
options: { id: string; source: "user" | "builtin"; path: string },
|
|
79
|
+
): WorkflowDefinition {
|
|
80
|
+
let parsed: unknown;
|
|
81
|
+
try {
|
|
82
|
+
parsed = parseYaml(yamlText);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
throw new WorkflowValidationError(`Invalid YAML: ${message}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!isRecord(parsed)) {
|
|
89
|
+
throw new WorkflowValidationError("Workflow root must be an object");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const name = requireString(parsed.name, "name");
|
|
93
|
+
const iterationCapRaw = parsed.iteration_cap;
|
|
94
|
+
if (iterationCapRaw === undefined) {
|
|
95
|
+
throw new WorkflowValidationError("iteration_cap is required");
|
|
96
|
+
}
|
|
97
|
+
if (typeof iterationCapRaw !== "number" || !Number.isInteger(iterationCapRaw) || iterationCapRaw < 1) {
|
|
98
|
+
throw new WorkflowValidationError("iteration_cap must be a positive integer");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!isRecord(parsed.steps) || Object.keys(parsed.steps).length === 0) {
|
|
102
|
+
throw new WorkflowValidationError("steps must be a non-empty object");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const steps: Record<string, WorkflowStep> = {};
|
|
106
|
+
for (const [stepName, stepValue] of Object.entries(parsed.steps)) {
|
|
107
|
+
steps[stepName] = parseStep(stepName, stepValue);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
validateTransitions(steps);
|
|
111
|
+
|
|
112
|
+
const entryStep = Object.keys(steps)[0];
|
|
113
|
+
return {
|
|
114
|
+
id: options.id,
|
|
115
|
+
source: options.source,
|
|
116
|
+
path: options.path,
|
|
117
|
+
name,
|
|
118
|
+
iteration_cap: iterationCapRaw,
|
|
119
|
+
steps,
|
|
120
|
+
entryStep,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateTransitions(steps: Record<string, WorkflowStep>): void {
|
|
125
|
+
const stepNames = new Set(Object.keys(steps));
|
|
126
|
+
|
|
127
|
+
for (const step of Object.values(steps)) {
|
|
128
|
+
if (step.kind === "linear") {
|
|
129
|
+
if (!stepNames.has(step.next)) {
|
|
130
|
+
throw new WorkflowValidationError(`steps.${step.name}.next references unknown step "${step.next}"`);
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (step.on_accept !== COMPLETE_TOKEN && !stepNames.has(step.on_accept)) {
|
|
136
|
+
throw new WorkflowValidationError(
|
|
137
|
+
`steps.${step.name}.on_accept references unknown step "${step.on_accept}"`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!stepNames.has(step.on_reject)) {
|
|
141
|
+
throw new WorkflowValidationError(
|
|
142
|
+
`steps.${step.name}.on_reject references unknown step "${step.on_reject}"`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function isCompleteTransition(target: string): boolean {
|
|
149
|
+
return target === COMPLETE_TOKEN;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function getReviewStep(step: WorkflowStep): WorkflowReviewStep | undefined {
|
|
153
|
+
return step.kind === "review" ? step : undefined;
|
|
154
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-baton",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Pi-native workflow baton runner with per-step model switching and isolated step context.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "eiei114",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi",
|
|
11
|
+
"pi-extension",
|
|
12
|
+
"workflow",
|
|
13
|
+
"review-loop",
|
|
14
|
+
"typescript"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/eiei114/pi-baton.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/eiei114/pi-baton/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/eiei114/pi-baton#readme",
|
|
24
|
+
"files": [
|
|
25
|
+
"extensions/",
|
|
26
|
+
"lib/",
|
|
27
|
+
"agents/",
|
|
28
|
+
"workflows/",
|
|
29
|
+
"docs/",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"CHANGELOG.md"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "node --test tests/*.test.mjs",
|
|
37
|
+
"ci": "npm run typecheck && npm test && npm run pack:check",
|
|
38
|
+
"pack:check": "npm pack --dry-run"
|
|
39
|
+
},
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./extensions"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"yaml": "^2.9.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@earendil-works/pi-ai": "*",
|
|
53
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
54
|
+
"typebox": "*"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@earendil-works/pi-ai": "latest",
|
|
58
|
+
"@earendil-works/pi-coding-agent": "latest",
|
|
59
|
+
"typebox": "latest",
|
|
60
|
+
"@types/node": "^22.0.0",
|
|
61
|
+
"typescript": "^6.0.3"
|
|
62
|
+
}
|
|
63
|
+
}
|