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,283 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Model } from "@earendil-works/pi-ai";
4
+ import {
5
+ buildFixHandoff,
6
+ buildImplementHandoff,
7
+ buildLinearHandoff,
8
+ buildStepPrompt,
9
+ } from "./handoff.ts";
10
+ import { getRunOutputsDir } from "./paths.ts";
11
+ import { resolveStepModel } from "./model-routing.ts";
12
+ import { ReviewContractError, parseStepEnvelope } from "./review-contract.ts";
13
+ import { readRunManifest, updateRunState, writeStepRecord } from "./run-store.ts";
14
+ import { loadWorkflowFromPath } from "./workflow-discovery.ts";
15
+ import { isCompleteTransition } from "./workflow-schema.ts";
16
+ import type { RunProgressUpdate } from "./run-widget.ts";
17
+ import type {
18
+ RunManifest,
19
+ StepRecord,
20
+ StepRunner,
21
+ StructuredStepEnvelope,
22
+ WorkflowDefinition,
23
+ WorkflowStep,
24
+ } from "./types.ts";
25
+
26
+ export interface RunEngineOptions {
27
+ cwd: string;
28
+ runId: string;
29
+ stepRunner: StepRunner;
30
+ sessionModel?: Model<any>;
31
+ signal?: AbortSignal;
32
+ onProgress?: (update: RunProgressUpdate) => void;
33
+ }
34
+
35
+ export interface RunResultSummary {
36
+ state: RunManifest["state"];
37
+ lastStep: string | null;
38
+ iteration: number;
39
+ runDirectory: string;
40
+ failureReason?: string;
41
+ }
42
+
43
+ function outputFileName(stepName: string, iteration: number): string {
44
+ return `${stepName}-${iteration}.md`;
45
+ }
46
+
47
+ async function saveRawOutput(
48
+ cwd: string,
49
+ runId: string,
50
+ stepName: string,
51
+ iteration: number,
52
+ text: string,
53
+ ): Promise<string> {
54
+ const filePath = join(getRunOutputsDir(cwd, runId), outputFileName(stepName, iteration));
55
+ await writeFile(filePath, text, "utf8");
56
+ return filePath;
57
+ }
58
+
59
+ function buildHandoff(
60
+ stepName: string,
61
+ manifest: RunManifest,
62
+ previousEnvelope?: StructuredStepEnvelope,
63
+ ) {
64
+ if (stepName === "implement") {
65
+ return buildImplementHandoff(manifest);
66
+ }
67
+
68
+ if (stepName === "fix" && previousEnvelope) {
69
+ return buildFixHandoff(manifest, previousEnvelope, previousEnvelope.findings ?? []);
70
+ }
71
+
72
+ if (previousEnvelope) {
73
+ return buildLinearHandoff(manifest, previousEnvelope);
74
+ }
75
+
76
+ return buildImplementHandoff(manifest);
77
+ }
78
+
79
+ function resolveNextStep(step: WorkflowStep, envelope: StructuredStepEnvelope): string | null {
80
+ if (step.kind === "linear") {
81
+ return step.next;
82
+ }
83
+
84
+ if (envelope.judgment === "accept") {
85
+ return isCompleteTransition(step.on_accept) ? null : step.on_accept;
86
+ }
87
+
88
+ return step.on_reject;
89
+ }
90
+
91
+ function emitProgress(options: RunEngineOptions, update: RunProgressUpdate): void {
92
+ options.onProgress?.(update);
93
+ }
94
+
95
+ async function executeOneStep(
96
+ options: RunEngineOptions,
97
+ workflow: WorkflowDefinition,
98
+ manifest: RunManifest,
99
+ stepName: string,
100
+ previousEnvelope?: StructuredStepEnvelope,
101
+ ): Promise<StructuredStepEnvelope> {
102
+ const step = workflow.steps[stepName];
103
+ if (!step) {
104
+ throw new Error(`Unknown step: ${stepName}`);
105
+ }
106
+
107
+ emitProgress(options, {
108
+ phase: "step-start",
109
+ manifest,
110
+ workflow,
111
+ stepName,
112
+ step,
113
+ });
114
+
115
+ const handoff = buildHandoff(stepName, manifest, previousEnvelope);
116
+ const prompt = buildStepPrompt(step.prompt, handoff);
117
+ const model = resolveStepModel(step.model, options.sessionModel);
118
+ const startedAt = new Date().toISOString();
119
+ const iteration = manifest.iteration;
120
+
121
+ const result = await options.stepRunner({
122
+ agent: step.agent,
123
+ prompt,
124
+ model,
125
+ cwd: manifest.targetDirectory,
126
+ signal: options.signal,
127
+ });
128
+
129
+ if (result.exitCode !== 0) {
130
+ throw new Error(result.stderr.trim() || `Step ${stepName} failed with exit code ${result.exitCode}`);
131
+ }
132
+
133
+ const rawOutputPath = await saveRawOutput(
134
+ options.cwd,
135
+ options.runId,
136
+ stepName,
137
+ iteration,
138
+ result.outputText,
139
+ );
140
+
141
+ const envelope = parseStepEnvelope(result.outputText, rawOutputPath, {
142
+ isReviewStep: step.kind === "review",
143
+ });
144
+
145
+ const record: StepRecord = {
146
+ stepName,
147
+ iteration,
148
+ agent: step.agent,
149
+ model: result.model ?? model,
150
+ startedAt,
151
+ finishedAt: new Date().toISOString(),
152
+ envelope,
153
+ exitCode: result.exitCode,
154
+ };
155
+
156
+ await writeStepRecord(options.cwd, options.runId, record);
157
+ return envelope;
158
+ }
159
+
160
+ export async function runContinuous(options: RunEngineOptions): Promise<RunResultSummary> {
161
+ let manifest = await readRunManifest(options.cwd, options.runId);
162
+
163
+ if (manifest.state === "completed" || manifest.state === "failed") {
164
+ throw new Error(`Run ${manifest.id} is terminal (${manifest.state})`);
165
+ }
166
+
167
+ if (manifest.state !== "idle" && manifest.state !== "running") {
168
+ throw new Error(`Run ${manifest.id} cannot be executed from state ${manifest.state}`);
169
+ }
170
+
171
+ manifest = await updateRunState(options.cwd, manifest.id, { state: "running" });
172
+ const workflow = await loadWorkflowFromPath(manifest.workflowPath, {
173
+ id: manifest.workflowId,
174
+ source: manifest.workflowSource,
175
+ });
176
+
177
+ emitProgress(options, {
178
+ phase: "run-start",
179
+ manifest,
180
+ workflow,
181
+ });
182
+
183
+ let currentStep = manifest.currentStep ?? manifest.entryStep;
184
+ let lastEnvelope: StructuredStepEnvelope | undefined;
185
+
186
+ try {
187
+ while (currentStep) {
188
+ const step = workflow.steps[currentStep];
189
+ if (!step) {
190
+ throw new Error(`Unknown step: ${currentStep}`);
191
+ }
192
+
193
+ if (step.kind === "review" && manifest.iteration >= manifest.iterationCap) {
194
+ manifest = await updateRunState(options.cwd, manifest.id, {
195
+ state: "failed",
196
+ failureReason: `Iteration cap (${manifest.iterationCap}) reached`,
197
+ lastStep: manifest.lastStep,
198
+ currentStep,
199
+ });
200
+ break;
201
+ }
202
+
203
+ const envelope = await executeOneStep(options, workflow, manifest, currentStep, lastEnvelope);
204
+ const nextStep = resolveNextStep(step, envelope);
205
+ const terminal = nextStep === null;
206
+
207
+ let nextIteration = manifest.iteration;
208
+ if (step.kind === "review" && envelope.judgment === "reject") {
209
+ nextIteration += 1;
210
+ }
211
+
212
+ manifest = await updateRunState(options.cwd, manifest.id, {
213
+ lastStep: currentStep,
214
+ currentStep: terminal ? null : nextStep,
215
+ iteration: nextIteration,
216
+ state: terminal ? "completed" : "running",
217
+ });
218
+
219
+ emitProgress(options, {
220
+ phase: "step-done",
221
+ manifest,
222
+ workflow,
223
+ stepName: currentStep,
224
+ step,
225
+ envelope,
226
+ });
227
+
228
+ lastEnvelope = envelope;
229
+
230
+ if (terminal) {
231
+ break;
232
+ }
233
+
234
+ currentStep = nextStep;
235
+ }
236
+ } catch (error) {
237
+ const failureReason =
238
+ error instanceof ReviewContractError || error instanceof Error
239
+ ? error.message
240
+ : String(error);
241
+
242
+ manifest = await updateRunState(options.cwd, manifest.id, {
243
+ state: "failed",
244
+ failureReason,
245
+ lastStep: manifest.lastStep,
246
+ currentStep,
247
+ });
248
+ }
249
+
250
+ const runDirectory = join(options.cwd, ".pi", "baton", "runs", manifest.id);
251
+
252
+ const result: RunResultSummary = {
253
+ state: manifest.state,
254
+ lastStep: manifest.lastStep,
255
+ iteration: manifest.iteration,
256
+ runDirectory,
257
+ failureReason: manifest.failureReason,
258
+ };
259
+
260
+ emitProgress(options, {
261
+ phase: "run-end",
262
+ manifest,
263
+ workflow,
264
+ result,
265
+ });
266
+
267
+ return result;
268
+ }
269
+
270
+ export function formatRunResultSummary(summary: RunResultSummary): string {
271
+ const lines = [
272
+ `state: ${summary.state}`,
273
+ `last step: ${summary.lastStep ?? "(none)"}`,
274
+ `iteration count: ${summary.iteration}`,
275
+ `run directory: ${summary.runDirectory}`,
276
+ ];
277
+
278
+ if (summary.failureReason) {
279
+ lines.push(`failure: ${summary.failureReason}`);
280
+ }
281
+
282
+ return lines.join("\n");
283
+ }
@@ -0,0 +1,136 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ ensureBatonScaffolding,
5
+ ensureRunDirs,
6
+ getActiveRunPointerPath,
7
+ getRunManifestPath,
8
+ getRunStepsDir,
9
+ } from "./paths.ts";
10
+ import type { ActiveRunPointer, RunManifest, RunState, StepRecord } from "./types.ts";
11
+
12
+ function nowIso(): string {
13
+ return new Date().toISOString();
14
+ }
15
+
16
+ async function readJson<T>(filePath: string): Promise<T> {
17
+ const text = await readFile(filePath, "utf8");
18
+ return JSON.parse(text) as T;
19
+ }
20
+
21
+ async function writeJson(filePath: string, value: unknown): Promise<void> {
22
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
23
+ }
24
+
25
+ export class ActiveRunGuardError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "ActiveRunGuardError";
29
+ }
30
+ }
31
+
32
+ export async function readActiveRunPointer(cwd: string): Promise<ActiveRunPointer | null> {
33
+ try {
34
+ return await readJson<ActiveRunPointer>(getActiveRunPointerPath(cwd));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export async function readRunManifest(cwd: string, runId: string): Promise<RunManifest> {
41
+ return readJson<RunManifest>(getRunManifestPath(cwd, runId));
42
+ }
43
+
44
+ export async function loadActiveRun(cwd: string): Promise<RunManifest | null> {
45
+ const pointer = await readActiveRunPointer(cwd);
46
+ if (!pointer) return null;
47
+
48
+ try {
49
+ const manifest = await readRunManifest(cwd, pointer.runId);
50
+ if (manifest.state === "completed" || manifest.state === "failed") {
51
+ return null;
52
+ }
53
+ return manifest;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export async function saveRunManifest(cwd: string, manifest: RunManifest): Promise<void> {
60
+ manifest.updatedAt = nowIso();
61
+ await writeJson(getRunManifestPath(cwd, manifest.id), manifest);
62
+ }
63
+
64
+ export async function setActiveRunPointer(cwd: string, runId: string): Promise<void> {
65
+ await writeJson(getActiveRunPointerPath(cwd), { runId } satisfies ActiveRunPointer);
66
+ }
67
+
68
+ export async function clearActiveRunPointer(cwd: string): Promise<void> {
69
+ await writeJson(getActiveRunPointerPath(cwd), { runId: null });
70
+ }
71
+
72
+ export interface CreateRunInput {
73
+ workflowId: string;
74
+ workflowName: string;
75
+ workflowPath: string;
76
+ workflowSource: "user" | "builtin";
77
+ taskBrief: string;
78
+ targetDirectory: string;
79
+ entryStep: string;
80
+ iterationCap: number;
81
+ }
82
+
83
+ export async function createIdleRun(cwd: string, input: CreateRunInput): Promise<RunManifest> {
84
+ await ensureBatonScaffolding(cwd);
85
+
86
+ const active = await loadActiveRun(cwd);
87
+ if (active) {
88
+ throw new ActiveRunGuardError(
89
+ `Active run ${active.id} is ${active.state}. Use /baton:status or /baton:run before starting a new run.`,
90
+ );
91
+ }
92
+
93
+ const timestamp = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
94
+ const runId = `${timestamp}-${randomUUID().slice(0, 8)}`;
95
+ await ensureRunDirs(cwd, runId);
96
+
97
+ const manifest: RunManifest = {
98
+ id: runId,
99
+ state: "idle",
100
+ workflowId: input.workflowId,
101
+ workflowName: input.workflowName,
102
+ workflowPath: input.workflowPath,
103
+ workflowSource: input.workflowSource,
104
+ taskBrief: input.taskBrief,
105
+ targetDirectory: input.targetDirectory,
106
+ entryStep: input.entryStep,
107
+ currentStep: input.entryStep,
108
+ lastStep: null,
109
+ iteration: 0,
110
+ iterationCap: input.iterationCap,
111
+ createdAt: nowIso(),
112
+ updatedAt: nowIso(),
113
+ };
114
+
115
+ await saveRunManifest(cwd, manifest);
116
+ await setActiveRunPointer(cwd, runId);
117
+ return manifest;
118
+ }
119
+
120
+ export async function updateRunState(
121
+ cwd: string,
122
+ runId: string,
123
+ patch: Partial<RunManifest> & { state?: RunState },
124
+ ): Promise<RunManifest> {
125
+ const manifest = await readRunManifest(cwd, runId);
126
+ const next = { ...manifest, ...patch, updatedAt: nowIso() };
127
+ await saveRunManifest(cwd, next);
128
+ return next;
129
+ }
130
+
131
+ export async function writeStepRecord(cwd: string, runId: string, record: StepRecord): Promise<string> {
132
+ const fileName = `${record.stepName}-${record.iteration}-${record.finishedAt.replace(/[:.]/g, "")}.json`;
133
+ const filePath = `${getRunStepsDir(cwd, runId)}/${fileName}`;
134
+ await writeJson(filePath, record);
135
+ return filePath;
136
+ }
package/lib/run-ui.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ buildRunStatusText,
4
+ buildRunStepChecklist,
5
+ buildRunWidgetLines,
6
+ type RunProgressUpdate,
7
+ } from "./run-widget.ts";
8
+
9
+ export interface RunUiController {
10
+ onProgress: (update: RunProgressUpdate) => void;
11
+ clear: () => void;
12
+ }
13
+
14
+ export function createRunUiController(ctx: ExtensionCommandContext): RunUiController {
15
+ const render = (update: RunProgressUpdate) => {
16
+ if (!ctx.hasUI) return;
17
+
18
+ const lines = [
19
+ ...buildRunWidgetLines(update).map((line, index) => {
20
+ if (index === 0) return ctx.ui.theme.fg("accent", line);
21
+ if (line.startsWith("status:")) return ctx.ui.theme.fg("warning", line);
22
+ if (line.startsWith("judgment: accept")) return ctx.ui.theme.fg("success", line);
23
+ if (line.startsWith("judgment: reject")) return ctx.ui.theme.fg("error", line);
24
+ if (line.startsWith("failure:")) return ctx.ui.theme.fg("error", line);
25
+ return ctx.ui.theme.fg("dim", line);
26
+ }),
27
+ "",
28
+ ...buildRunStepChecklist(update.workflow, update).map((line) => {
29
+ if (line.startsWith(">")) return ctx.ui.theme.fg("warning", line);
30
+ if (line.startsWith("✓")) return ctx.ui.theme.fg("success", line);
31
+ return ctx.ui.theme.fg("dim", line);
32
+ }),
33
+ ];
34
+
35
+ ctx.ui.setWidget("baton-run", lines, { placement: "aboveEditor" });
36
+
37
+ const status = buildRunStatusText(update);
38
+ if (status) {
39
+ ctx.ui.setStatus("baton", ctx.ui.theme.fg("accent", status));
40
+ }
41
+ };
42
+
43
+ return {
44
+ onProgress: render,
45
+ clear: () => {
46
+ if (!ctx.hasUI) return;
47
+ ctx.ui.setWidget("baton-run", undefined);
48
+ ctx.ui.setStatus("baton", undefined);
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,110 @@
1
+ import type { RunManifest, StructuredStepEnvelope, WorkflowDefinition, WorkflowStep } from "./types.ts";
2
+ import type { RunResultSummary } from "./run-engine.ts";
3
+
4
+ export type RunProgressPhase = "run-start" | "step-start" | "step-done" | "run-end";
5
+
6
+ export interface RunProgressUpdate {
7
+ phase: RunProgressPhase;
8
+ manifest: RunManifest;
9
+ workflow: WorkflowDefinition;
10
+ stepName?: string;
11
+ step?: WorkflowStep;
12
+ envelope?: StructuredStepEnvelope;
13
+ result?: RunResultSummary;
14
+ }
15
+
16
+ function truncate(text: string, max = 72): string {
17
+ const trimmed = text.trim().replace(/\s+/g, " ");
18
+ if (trimmed.length <= max) return trimmed;
19
+ return `${trimmed.slice(0, max - 3)}...`;
20
+ }
21
+
22
+ function stepLabel(stepName: string, step?: WorkflowStep): string {
23
+ if (!step) return stepName;
24
+ const kind = step.kind === "review" ? "review" : "linear";
25
+ return `${stepName} (${step.agent}, ${kind})`;
26
+ }
27
+
28
+ export function buildRunWidgetLines(update: RunProgressUpdate): string[] {
29
+ const { manifest, workflow, phase, stepName, step, envelope, result } = update;
30
+ const lines: string[] = ["Baton run"];
31
+
32
+ lines.push(`workflow: ${manifest.workflowName}`);
33
+ lines.push(`brief: ${truncate(manifest.taskBrief)}`);
34
+ lines.push(`iteration: ${manifest.iteration}/${manifest.iterationCap}`);
35
+
36
+ if (phase === "run-start") {
37
+ lines.push("status: starting...");
38
+ return lines;
39
+ }
40
+
41
+ if (phase === "step-start" && stepName) {
42
+ lines.push(`status: running ${stepLabel(stepName, step)}`);
43
+ if (step?.model) lines.push(`model: ${step.model}`);
44
+ return lines;
45
+ }
46
+
47
+ if (phase === "step-done" && stepName) {
48
+ lines.push(`status: finished ${stepLabel(stepName, step)}`);
49
+ if (envelope?.summary) lines.push(`summary: ${truncate(envelope.summary, 96)}`);
50
+ if (envelope?.judgment) {
51
+ lines.push(`judgment: ${envelope.judgment}`);
52
+ if (envelope.judgment === "reject" && envelope.findings?.length) {
53
+ lines.push(`findings: ${envelope.findings.length}`);
54
+ }
55
+ }
56
+ if (manifest.currentStep) {
57
+ lines.push(`next: ${manifest.currentStep}`);
58
+ }
59
+ return lines;
60
+ }
61
+
62
+ if (phase === "run-end" && result) {
63
+ lines.push(`status: ${result.state}`);
64
+ lines.push(`last step: ${result.lastStep ?? "(none)"}`);
65
+ if (result.failureReason) lines.push(`failure: ${truncate(result.failureReason, 96)}`);
66
+ return lines;
67
+ }
68
+
69
+ lines.push(`state: ${manifest.state}`);
70
+ if (manifest.currentStep) lines.push(`current step: ${manifest.currentStep}`);
71
+ return lines;
72
+ }
73
+
74
+ export function buildRunStatusText(update: RunProgressUpdate): string | undefined {
75
+ const { phase, manifest, stepName, result } = update;
76
+
77
+ if (phase === "run-start") return "baton: starting";
78
+ if (phase === "step-start" && stepName) return `baton: ${stepName}`;
79
+ if (phase === "step-done" && stepName) return `baton: ${stepName} done`;
80
+ if (phase === "run-end" && result) {
81
+ return result.state === "completed" ? "baton: completed" : "baton: failed";
82
+ }
83
+
84
+ if (manifest.state === "running" && manifest.currentStep) {
85
+ return `baton: ${manifest.currentStep}`;
86
+ }
87
+
88
+ return undefined;
89
+ }
90
+
91
+ export function listWorkflowSteps(workflow: WorkflowDefinition): string[] {
92
+ return Object.keys(workflow.steps);
93
+ }
94
+
95
+ export function buildRunStepChecklist(
96
+ workflow: WorkflowDefinition,
97
+ update: RunProgressUpdate,
98
+ ): string[] {
99
+ const steps = listWorkflowSteps(workflow);
100
+ const current = update.stepName ?? update.manifest.currentStep ?? "";
101
+ const last = update.manifest.lastStep ?? "";
102
+
103
+ return steps.map((name) => {
104
+ if (name === current && update.phase === "step-start") return `> ${name}`;
105
+ if (last === name || (update.phase === "step-done" && update.stepName === name)) {
106
+ return `✓ ${name}`;
107
+ }
108
+ return ` ${name}`;
109
+ });
110
+ }
package/lib/schema.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { Type, type TEnum, type TSchemaOptions } from "typebox";
2
+
3
+ export function StringEnum<const Values extends [string, ...string[]]>(
4
+ values: readonly [...Values],
5
+ options?: TSchemaOptions,
6
+ ): TEnum<Values> {
7
+ return Type.Enum([...values] as [string, ...string[]], options) as unknown as TEnum<Values>;
8
+ }
package/lib/status.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { RunManifest } from "./types.ts";
2
+
3
+ export function formatStatusSummary(manifest: RunManifest): string {
4
+ return [
5
+ `workflow: ${manifest.workflowName}`,
6
+ `task brief: ${manifest.taskBrief}`,
7
+ `current step: ${manifest.currentStep ?? "(none)"}`,
8
+ `run state: ${manifest.state}`,
9
+ `iteration count: ${manifest.iteration}`,
10
+ `run directory: .pi/baton/runs/${manifest.id}`,
11
+ ].join("\n");
12
+ }
13
+
14
+ export const NO_ACTIVE_RUN_MESSAGE =
15
+ "No active Baton run. Start one with /baton:start after choosing a workflow and task brief.";