pi-baton 0.7.5 → 0.8.5
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 +202 -163
- package/README.md +184 -179
- package/docs/workflows.md +154 -0
- package/extensions/index.ts +14 -1
- package/lib/run-store.ts +113 -1
- package/lib/status.ts +43 -0
- package/lib/workflow-discovery.ts +66 -26
- package/package.json +67 -67
package/lib/run-store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import {
|
|
4
4
|
ensureBatonScaffolding,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getActiveRunPointerPath,
|
|
7
7
|
getRunManifestPath,
|
|
8
8
|
getRunStepsDir,
|
|
9
|
+
getRunsDir,
|
|
9
10
|
} from "./paths.ts";
|
|
10
11
|
import type { ActiveRunPointer, RunManifest, RunState, StepRecord } from "./types.ts";
|
|
11
12
|
|
|
@@ -60,6 +61,117 @@ export async function loadActiveRun(cwd: string): Promise<RunManifest | null> {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
export const TERMINAL_HISTORY_DEFAULT_LIMIT = 10;
|
|
65
|
+
export const TERMINAL_HISTORY_HARD_CAP = 20;
|
|
66
|
+
export const TERMINAL_HISTORY_MAX_SCAN = 200;
|
|
67
|
+
|
|
68
|
+
export interface TerminalRunHistoryResult {
|
|
69
|
+
runs: RunManifest[];
|
|
70
|
+
skippedCount: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isCompleteManifest(value: unknown): value is RunManifest {
|
|
74
|
+
if (!value || typeof value !== "object") return false;
|
|
75
|
+
|
|
76
|
+
const manifest = value as Partial<RunManifest>;
|
|
77
|
+
return (
|
|
78
|
+
typeof manifest.id === "string" &&
|
|
79
|
+
typeof manifest.state === "string" &&
|
|
80
|
+
typeof manifest.workflowName === "string" &&
|
|
81
|
+
typeof manifest.updatedAt === "string" &&
|
|
82
|
+
typeof manifest.createdAt === "string" &&
|
|
83
|
+
typeof manifest.iteration === "number" &&
|
|
84
|
+
(manifest.lastStep === null || typeof manifest.lastStep === "string")
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runIdTimestamp(runId: string): string {
|
|
89
|
+
const prefix = runId.slice(0, 14);
|
|
90
|
+
return /^\d{14}$/.test(prefix) ? prefix : "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function compareRunIdsNewestFirst(a: string, b: string): number {
|
|
94
|
+
const aTimestamp = runIdTimestamp(a);
|
|
95
|
+
const bTimestamp = runIdTimestamp(b);
|
|
96
|
+
if (aTimestamp && bTimestamp && aTimestamp !== bTimestamp) {
|
|
97
|
+
return bTimestamp.localeCompare(aTimestamp);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return b.localeCompare(a);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function compareRunsNewestFirst(a: RunManifest, b: RunManifest): number {
|
|
104
|
+
const updatedCompare = b.updatedAt.localeCompare(a.updatedAt);
|
|
105
|
+
if (updatedCompare !== 0) return updatedCompare;
|
|
106
|
+
|
|
107
|
+
const createdCompare = b.createdAt.localeCompare(a.createdAt);
|
|
108
|
+
if (createdCompare !== 0) return createdCompare;
|
|
109
|
+
|
|
110
|
+
return compareRunIdsNewestFirst(a.id, b.id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function loadTerminalRunHistory(
|
|
114
|
+
cwd: string,
|
|
115
|
+
options: { limit?: number } = {},
|
|
116
|
+
): Promise<TerminalRunHistoryResult> {
|
|
117
|
+
await ensureBatonScaffolding(cwd);
|
|
118
|
+
|
|
119
|
+
const limit = Math.min(
|
|
120
|
+
Math.max(1, options.limit ?? TERMINAL_HISTORY_DEFAULT_LIMIT),
|
|
121
|
+
TERMINAL_HISTORY_HARD_CAP,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
let entries;
|
|
125
|
+
try {
|
|
126
|
+
entries = await readdir(getRunsDir(cwd), { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
return { runs: [], skippedCount: 0 };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const runIds = entries
|
|
132
|
+
.filter((entry) => entry.isDirectory())
|
|
133
|
+
.map((entry) => entry.name)
|
|
134
|
+
.sort(compareRunIdsNewestFirst)
|
|
135
|
+
.slice(0, TERMINAL_HISTORY_MAX_SCAN);
|
|
136
|
+
|
|
137
|
+
const scanResults = await Promise.all(
|
|
138
|
+
runIds.map(async (runId) => {
|
|
139
|
+
try {
|
|
140
|
+
const manifest = await readJson<unknown>(getRunManifestPath(cwd, runId));
|
|
141
|
+
if (!isCompleteManifest(manifest)) {
|
|
142
|
+
return { kind: "skipped" as const };
|
|
143
|
+
}
|
|
144
|
+
if (!isTerminalRunState(manifest.state)) {
|
|
145
|
+
return { kind: "non-terminal" as const };
|
|
146
|
+
}
|
|
147
|
+
return { kind: "terminal" as const, manifest };
|
|
148
|
+
} catch {
|
|
149
|
+
return { kind: "skipped" as const };
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const terminalRuns: RunManifest[] = [];
|
|
155
|
+
let skippedCount = 0;
|
|
156
|
+
|
|
157
|
+
for (const result of scanResults) {
|
|
158
|
+
if (result.kind === "skipped") {
|
|
159
|
+
skippedCount++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (result.kind === "terminal") {
|
|
163
|
+
terminalRuns.push(result.manifest);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
terminalRuns.sort(compareRunsNewestFirst);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
runs: terminalRuns.slice(0, limit),
|
|
171
|
+
skippedCount,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
63
175
|
export async function loadMostRecentTerminalRun(cwd: string): Promise<RunManifest | null> {
|
|
64
176
|
const pointer = await readActiveRunPointer(cwd);
|
|
65
177
|
if (!pointer?.runId) return null;
|
package/lib/status.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TerminalRunHistoryResult } from "./run-store.ts";
|
|
1
2
|
import type { RunManifest } from "./types.ts";
|
|
2
3
|
|
|
3
4
|
function isTerminalRunState(state: RunManifest["state"]): boolean {
|
|
@@ -21,8 +22,50 @@ export function formatStatusSummary(manifest: RunManifest): string {
|
|
|
21
22
|
`run directory: .pi/baton/runs/${manifest.id}`,
|
|
22
23
|
);
|
|
23
24
|
|
|
25
|
+
if (manifest.failureReason) {
|
|
26
|
+
lines.push(`failure: ${manifest.failureReason}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
return lines.join("\n");
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export const NO_ACTIVE_RUN_MESSAGE =
|
|
28
33
|
"No active Baton run. Start one with /baton:start after choosing a workflow and task brief.";
|
|
34
|
+
|
|
35
|
+
export const NO_TERMINAL_HISTORY_MESSAGE =
|
|
36
|
+
"No completed or failed Baton runs yet. Start one with /baton:start, then /baton:run.";
|
|
37
|
+
|
|
38
|
+
function formatRelativeRunDirectory(runId: string): string {
|
|
39
|
+
return `.pi/baton/runs/${runId}`.replace(/\\/g, "/");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatHistoryRow(manifest: RunManifest): string {
|
|
43
|
+
const lastStep = manifest.lastStep ?? "(none)";
|
|
44
|
+
return [
|
|
45
|
+
manifest.state,
|
|
46
|
+
manifest.id,
|
|
47
|
+
manifest.workflowName,
|
|
48
|
+
`last: ${lastStep}`,
|
|
49
|
+
`iter: ${manifest.iteration}`,
|
|
50
|
+
`updated: ${manifest.updatedAt}`,
|
|
51
|
+
formatRelativeRunDirectory(manifest.id),
|
|
52
|
+
].join(" | ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatHistorySummary(result: TerminalRunHistoryResult): string {
|
|
56
|
+
const lines: string[] = [];
|
|
57
|
+
|
|
58
|
+
if (result.runs.length === 0) {
|
|
59
|
+
lines.push(NO_TERMINAL_HISTORY_MESSAGE);
|
|
60
|
+
} else {
|
|
61
|
+
for (const manifest of result.runs) {
|
|
62
|
+
lines.push(formatHistoryRow(manifest));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (result.skippedCount > 0) {
|
|
67
|
+
lines.push(`(${result.skippedCount} run(s) skipped: unreadable or incomplete manifest)`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
@@ -15,42 +15,82 @@ async function listYamlFiles(dir: string): Promise<string[]> {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
async function loadWorkflowItems(
|
|
19
|
+
filePaths: string[],
|
|
20
|
+
source: "user" | "builtin",
|
|
21
|
+
): Promise<WorkflowListItem[]> {
|
|
22
|
+
return Promise.all(
|
|
23
|
+
filePaths.map(async (filePath) => {
|
|
24
|
+
const yamlText = await readFile(filePath, "utf8");
|
|
25
|
+
const id = basename(filePath, ".yaml");
|
|
26
|
+
const workflow = parseWorkflowDocument(yamlText, { id, source, path: filePath });
|
|
27
|
+
return { id, name: workflow.name, source, path: filePath };
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export async function discoverWorkflowItems(cwd: string): Promise<WorkflowListItem[]> {
|
|
19
|
-
const userFiles = await
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
}
|
|
33
|
+
const [userFiles, builtinFiles] = await Promise.all([
|
|
34
|
+
listYamlFiles(getWorkflowsDir(cwd)),
|
|
35
|
+
listYamlFiles(getPackageWorkflowsDir()),
|
|
36
|
+
]);
|
|
29
37
|
|
|
30
|
-
const builtinItems
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const workflow = parseWorkflowDocument(yamlText, { id, source: "builtin", path: filePath });
|
|
35
|
-
builtinItems.push({ id, name: workflow.name, source: "builtin", path: filePath });
|
|
36
|
-
}
|
|
38
|
+
const [userItems, builtinItems] = await Promise.all([
|
|
39
|
+
loadWorkflowItems(userFiles, "user"),
|
|
40
|
+
loadWorkflowItems(builtinFiles, "builtin"),
|
|
41
|
+
]);
|
|
37
42
|
|
|
38
43
|
return [...userItems, ...builtinItems];
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
function isMissingFileError(error: unknown): boolean {
|
|
47
|
+
return (
|
|
48
|
+
typeof error === "object" &&
|
|
49
|
+
error !== null &&
|
|
50
|
+
"code" in error &&
|
|
51
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const UNSAFE_WORKFLOW_ID_PATTERN = /[\\/\0]/;
|
|
56
|
+
|
|
57
|
+
function isSafeWorkflowId(workflowId: string): boolean {
|
|
58
|
+
return (
|
|
59
|
+
workflowId.length > 0 &&
|
|
60
|
+
workflowId !== "." &&
|
|
61
|
+
workflowId !== ".." &&
|
|
62
|
+
!UNSAFE_WORKFLOW_ID_PATTERN.test(workflowId)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
41
66
|
export async function loadWorkflowById(cwd: string, workflowId: string): Promise<WorkflowDefinition> {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (!
|
|
67
|
+
// The direct lookup builds a path from the id, so ids that could escape the
|
|
68
|
+
// workflow directories are rejected before any read is attempted.
|
|
69
|
+
if (!isSafeWorkflowId(workflowId)) {
|
|
45
70
|
throw new Error(`Unknown workflow: ${workflowId}`);
|
|
46
71
|
}
|
|
47
72
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
source:
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
const userPath = join(getWorkflowsDir(cwd), `${workflowId}.yaml`);
|
|
74
|
+
try {
|
|
75
|
+
const yamlText = await readFile(userPath, "utf8");
|
|
76
|
+
return parseWorkflowDocument(yamlText, { id: workflowId, source: "user", path: userPath });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!isMissingFileError(error)) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const builtinPath = join(getPackageWorkflowsDir(), `${workflowId}.yaml`);
|
|
84
|
+
try {
|
|
85
|
+
const yamlText = await readFile(builtinPath, "utf8");
|
|
86
|
+
return parseWorkflowDocument(yamlText, { id: workflowId, source: "builtin", path: builtinPath });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!isMissingFileError(error)) {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new Error(`Unknown workflow: ${workflowId}`);
|
|
54
94
|
}
|
|
55
95
|
|
|
56
96
|
export async function loadWorkflowFromPath(
|
package/package.json
CHANGED
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-baton",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi-native workflow baton runner with per-step model switching and isolated step context.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"engines": {
|
|
7
|
-
"node": ">=22.6.0"
|
|
8
|
-
},
|
|
9
|
-
"license": "MIT",
|
|
10
|
-
"author": "eiei114",
|
|
11
|
-
"keywords": [
|
|
12
|
-
"pi-package",
|
|
13
|
-
"pi",
|
|
14
|
-
"pi-extension",
|
|
15
|
-
"workflow",
|
|
16
|
-
"review-loop",
|
|
17
|
-
"typescript"
|
|
18
|
-
],
|
|
19
|
-
"repository": {
|
|
20
|
-
"type": "git",
|
|
21
|
-
"url": "git+https://github.com/eiei114/pi-baton.git"
|
|
22
|
-
},
|
|
23
|
-
"bugs": {
|
|
24
|
-
"url": "https://github.com/eiei114/pi-baton/issues"
|
|
25
|
-
},
|
|
26
|
-
"homepage": "https://github.com/eiei114/pi-baton#readme",
|
|
27
|
-
"files": [
|
|
28
|
-
"assets/",
|
|
29
|
-
"extensions/",
|
|
30
|
-
"lib/",
|
|
31
|
-
"agents/",
|
|
32
|
-
"workflows/",
|
|
33
|
-
"docs/",
|
|
34
|
-
"README.md",
|
|
35
|
-
"LICENSE",
|
|
36
|
-
"CHANGELOG.md"
|
|
37
|
-
],
|
|
38
|
-
"scripts": {
|
|
39
|
-
"typecheck": "tsc --noEmit",
|
|
40
|
-
"test": "node --test tests/*.test.mjs",
|
|
41
|
-
"ci": "npm run typecheck && npm test && npm run pack:check",
|
|
42
|
-
"pack:check": "npm pack --dry-run"
|
|
43
|
-
},
|
|
44
|
-
"pi": {
|
|
45
|
-
"extensions": [
|
|
46
|
-
"./extensions"
|
|
47
|
-
]
|
|
48
|
-
},
|
|
49
|
-
"publishConfig": {
|
|
50
|
-
"access": "public"
|
|
51
|
-
},
|
|
52
|
-
"dependencies": {
|
|
53
|
-
"yaml": "^2.9.0"
|
|
54
|
-
},
|
|
55
|
-
"peerDependencies": {
|
|
56
|
-
"@earendil-works/pi-ai": "*",
|
|
57
|
-
"@earendil-works/pi-coding-agent": "*",
|
|
58
|
-
"typebox": "*"
|
|
59
|
-
},
|
|
60
|
-
"devDependencies": {
|
|
61
|
-
"@earendil-works/pi-ai": "^0.84.1",
|
|
62
|
-
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
63
|
-
"typebox": "latest",
|
|
64
|
-
"@types/node": "^26.0.1",
|
|
65
|
-
"typescript": "^7.0.2"
|
|
66
|
-
}
|
|
67
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-baton",
|
|
3
|
+
"version": "0.8.5",
|
|
4
|
+
"description": "Pi-native workflow baton runner with per-step model switching and isolated step context.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.6.0"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "eiei114",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi",
|
|
14
|
+
"pi-extension",
|
|
15
|
+
"workflow",
|
|
16
|
+
"review-loop",
|
|
17
|
+
"typescript"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/eiei114/pi-baton.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/eiei114/pi-baton/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/eiei114/pi-baton#readme",
|
|
27
|
+
"files": [
|
|
28
|
+
"assets/",
|
|
29
|
+
"extensions/",
|
|
30
|
+
"lib/",
|
|
31
|
+
"agents/",
|
|
32
|
+
"workflows/",
|
|
33
|
+
"docs/",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"CHANGELOG.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "node --test tests/*.test.mjs",
|
|
41
|
+
"ci": "npm run typecheck && npm test && npm run pack:check",
|
|
42
|
+
"pack:check": "npm pack --dry-run"
|
|
43
|
+
},
|
|
44
|
+
"pi": {
|
|
45
|
+
"extensions": [
|
|
46
|
+
"./extensions"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"yaml": "^2.9.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@earendil-works/pi-ai": "*",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
58
|
+
"typebox": "*"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
62
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
63
|
+
"typebox": "latest",
|
|
64
|
+
"@types/node": "^26.0.1",
|
|
65
|
+
"typescript": "^7.0.2"
|
|
66
|
+
}
|
|
67
|
+
}
|