pi-plans 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/LICENSE +21 -0
- package/README.md +217 -0
- package/agents/criticizer.md +18 -0
- package/agents/reviewer.md +20 -0
- package/docs/assets/pi-plans-logo.svg +66 -0
- package/index.ts +375 -0
- package/package.json +58 -0
- package/references/pi-planning-workflow.md +154 -0
- package/references/plan-artifact-template.md +77 -0
- package/references/state-and-config.md +137 -0
- package/scripts/run-tests.ts +31 -0
- package/scripts/validate.ts +185 -0
- package/skills/debug-and-plan/SKILL.md +35 -0
- package/skills/plan-big/SKILL.md +24 -0
- package/skills/plan-normal/SKILL.md +24 -0
- package/skills/plan-small/SKILL.md +23 -0
- package/skills/plan-with-refs/SKILL.md +30 -0
- package/skills/planning/SKILL.md +22 -0
- package/src/exec.ts +317 -0
- package/src/execution-panel.ts +497 -0
- package/src/guard.ts +38 -0
- package/src/plan.ts +63 -0
- package/src/refine-prompts.ts +70 -0
- package/src/state.ts +490 -0
- package/src/subagent.ts +197 -0
- package/tests/exec.test.ts +249 -0
- package/tests/execution-panel.test.ts +198 -0
- package/tests/guard.test.ts +70 -0
- package/tests/plan.test.ts +105 -0
- package/tests/refine-prompts.test.ts +49 -0
- package/tests/state.test.ts +240 -0
- package/tools/ask-choice.ts +199 -0
- package/tools/execute-plan.ts +137 -0
- package/tools/plans.ts +195 -0
- package/tools/refine.ts +237 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-plans workspace state, persisted under <git-common-dir>/pi_plans/.
|
|
3
|
+
*
|
|
4
|
+
* State lives inside the resolved git common dir (`git rev-parse
|
|
5
|
+
* --git-common-dir`) as `.git/pi_plans/`, so it is never tracked and needs no
|
|
6
|
+
* .gitignore rules. When the workdir is not a git repository, mutating
|
|
7
|
+
* functions auto-run `git init` (never commits) under the same safety
|
|
8
|
+
* conditions as the original helper.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
export const STATE_DIRNAME = "pi_plans";
|
|
18
|
+
const GIT_ENV_SCRUB = ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"];
|
|
19
|
+
|
|
20
|
+
export class StateError extends Error {}
|
|
21
|
+
|
|
22
|
+
export type SettingSource = "user" | "auto" | "unset";
|
|
23
|
+
|
|
24
|
+
export interface LanguageConfig {
|
|
25
|
+
tag: string | null;
|
|
26
|
+
source: SettingSource;
|
|
27
|
+
updated_at: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RoleConfig {
|
|
31
|
+
mode: string;
|
|
32
|
+
model_selector: string | null;
|
|
33
|
+
name_prefix: string;
|
|
34
|
+
confirmed_at: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PlansConfig {
|
|
38
|
+
schema: number;
|
|
39
|
+
language: LanguageConfig;
|
|
40
|
+
reviewer: RoleConfig;
|
|
41
|
+
criticizer: RoleConfig;
|
|
42
|
+
artifact_root: string;
|
|
43
|
+
artifact_root_source: SettingSource;
|
|
44
|
+
artifact_root_updated_at: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_ARTIFACT_ROOT = "./docs/pi-plans";
|
|
48
|
+
const LEGACY_ARTIFACT_ROOTS = new Set(["docs/plans", "./docs/plans"]);
|
|
49
|
+
|
|
50
|
+
function normalizeArtifactRoot(config: PlansConfig): PlansConfig {
|
|
51
|
+
if (LEGACY_ARTIFACT_ROOTS.has(config.artifact_root)) {
|
|
52
|
+
return { ...config, artifact_root: DEFAULT_ARTIFACT_ROOT };
|
|
53
|
+
}
|
|
54
|
+
return config;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_CONFIG: PlansConfig = {
|
|
58
|
+
schema: 1,
|
|
59
|
+
language: { tag: null, source: "unset", updated_at: null },
|
|
60
|
+
reviewer: {
|
|
61
|
+
mode: "delegated-subagent",
|
|
62
|
+
model_selector: null,
|
|
63
|
+
name_prefix: "pi-plans-reviewer",
|
|
64
|
+
confirmed_at: null,
|
|
65
|
+
},
|
|
66
|
+
criticizer: {
|
|
67
|
+
mode: "delegated-subagent",
|
|
68
|
+
model_selector: null,
|
|
69
|
+
name_prefix: "pi-plans-criticizer",
|
|
70
|
+
confirmed_at: null,
|
|
71
|
+
},
|
|
72
|
+
artifact_root: DEFAULT_ARTIFACT_ROOT,
|
|
73
|
+
artifact_root_source: "unset",
|
|
74
|
+
artifact_root_updated_at: null,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const VALID_ROLE_MODES = new Set(["delegated-subagent", "current-session"]);
|
|
78
|
+
export const VALID_RUN_STATUSES = new Set([
|
|
79
|
+
"planning",
|
|
80
|
+
"accepted",
|
|
81
|
+
"executing",
|
|
82
|
+
"stopped",
|
|
83
|
+
"abandoned",
|
|
84
|
+
"done",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
export interface RunInfo {
|
|
88
|
+
schema: number;
|
|
89
|
+
run_id: string;
|
|
90
|
+
skill: string;
|
|
91
|
+
topic: string;
|
|
92
|
+
request_text: string;
|
|
93
|
+
workdir: string;
|
|
94
|
+
artifact_dir: string;
|
|
95
|
+
language_tag: string | null;
|
|
96
|
+
status: string;
|
|
97
|
+
created_at: string;
|
|
98
|
+
updated_at: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ActiveInfo {
|
|
102
|
+
run_id: string;
|
|
103
|
+
run_dir: string;
|
|
104
|
+
artifact_dir: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface DecisionEntry {
|
|
108
|
+
question: string;
|
|
109
|
+
options: string[];
|
|
110
|
+
answer: string;
|
|
111
|
+
answer_source: "user" | "auto-complete";
|
|
112
|
+
artifact?: string;
|
|
113
|
+
recorded_at: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface RefEntry {
|
|
117
|
+
title: string;
|
|
118
|
+
url: string;
|
|
119
|
+
kind: string;
|
|
120
|
+
retrieval: string;
|
|
121
|
+
local_path?: string;
|
|
122
|
+
coverage?: string;
|
|
123
|
+
gaps?: string;
|
|
124
|
+
recorded_at: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface SubagentEntry {
|
|
128
|
+
role: "reviewer" | "criticizer";
|
|
129
|
+
name: string;
|
|
130
|
+
model?: string | null;
|
|
131
|
+
session_dir?: string;
|
|
132
|
+
recorded_at: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Test hook so tests can pin the clock (run-id dedup etc.). */
|
|
136
|
+
export const testHooks: { now: () => Date } = { now: () => new Date() };
|
|
137
|
+
|
|
138
|
+
export function utcNow(): string {
|
|
139
|
+
return testHooks.now().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Git resolution
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
interface GitResult {
|
|
147
|
+
code: number;
|
|
148
|
+
stdout: string;
|
|
149
|
+
stderr: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function runGit(workdir: string, ...args: string[]): GitResult {
|
|
153
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
154
|
+
for (const key of GIT_ENV_SCRUB) delete env[key];
|
|
155
|
+
const result = spawnSync("git", args, { cwd: workdir, env, encoding: "utf8" });
|
|
156
|
+
if (result.error) {
|
|
157
|
+
throw new StateError("git executable not found; pi-plans state requires git");
|
|
158
|
+
}
|
|
159
|
+
return { code: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function resolveGitCommonDir(workdir: string): string | null {
|
|
163
|
+
const result = runGit(workdir, "rev-parse", "--git-common-dir");
|
|
164
|
+
if (result.code !== 0) return null;
|
|
165
|
+
const raw = result.stdout.trim();
|
|
166
|
+
if (!raw) return null;
|
|
167
|
+
return path.resolve(workdir, raw);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function ensureNotBare(workdir: string): void {
|
|
171
|
+
const result = runGit(workdir, "rev-parse", "--is-bare-repository");
|
|
172
|
+
if (result.code === 0 && result.stdout.trim() === "true") {
|
|
173
|
+
throw new StateError("pi-plans state is not supported in bare repositories");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function normalizeWorkdir(value: string): string {
|
|
178
|
+
const expanded = value === "~" || value.startsWith("~/")
|
|
179
|
+
? path.join(os.homedir(), value.slice(1))
|
|
180
|
+
: value;
|
|
181
|
+
return path.resolve(expanded);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve the git common dir, auto-initializing a repo when safe.
|
|
186
|
+
* Auto-init only runs when the workdir has no `.git` entry AND is not inside
|
|
187
|
+
* any work tree AND is not the home directory or filesystem root.
|
|
188
|
+
*/
|
|
189
|
+
export function ensureGitRepo(workdir: string, notices: string[]): string {
|
|
190
|
+
const common = resolveGitCommonDir(workdir);
|
|
191
|
+
if (common !== null) {
|
|
192
|
+
ensureNotBare(workdir);
|
|
193
|
+
return common;
|
|
194
|
+
}
|
|
195
|
+
if (existsSync(path.join(workdir, ".git"))) {
|
|
196
|
+
throw new StateError(
|
|
197
|
+
`${workdir} has a .git entry but git cannot resolve it; repair or remove it before running pi-plans`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const inside = runGit(workdir, "rev-parse", "--is-inside-work-tree");
|
|
201
|
+
if (inside.code === 0 && inside.stdout.trim() === "true") {
|
|
202
|
+
throw new StateError("git resolution failed despite being inside a work tree; check your git setup");
|
|
203
|
+
}
|
|
204
|
+
if (path.resolve(workdir) === path.resolve(os.homedir()) || path.dirname(path.resolve(workdir)) === path.resolve(workdir)) {
|
|
205
|
+
throw new StateError(
|
|
206
|
+
"refusing to auto-initialize a git repository in the home directory or filesystem root; pass a project workdir",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
notices.push(`no git repository found in ${workdir}; ran git init to store state`);
|
|
210
|
+
const init = runGit(workdir, "init");
|
|
211
|
+
if (init.code !== 0) {
|
|
212
|
+
throw new StateError(`git init failed in ${workdir}: ${init.stderr.trim()}`);
|
|
213
|
+
}
|
|
214
|
+
const created = resolveGitCommonDir(workdir);
|
|
215
|
+
if (created === null) throw new StateError("git init succeeded but git dir resolution still fails");
|
|
216
|
+
return created;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Read-only state root resolution; returns null when no repo exists. */
|
|
220
|
+
export function resolveStateRootOrNull(workdir: string): string | null {
|
|
221
|
+
const common = resolveGitCommonDir(workdir);
|
|
222
|
+
if (common === null) return null;
|
|
223
|
+
return path.join(common, STATE_DIRNAME);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Config helpers
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
function atomicWriteJson(filePath: string, data: unknown): void {
|
|
231
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
232
|
+
const tmp = `${filePath}.tmp`;
|
|
233
|
+
writeFileSync(tmp, `${JSON.stringify(data, null, "\t")}\n`, "utf8");
|
|
234
|
+
renameSync(tmp, filePath);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function deepMergeDefaults<T>(data: T, defaults: T): T {
|
|
238
|
+
const merged: Record<string, unknown> = { ...(data as Record<string, unknown>) };
|
|
239
|
+
for (const [key, value] of Object.entries(defaults as Record<string, unknown>)) {
|
|
240
|
+
if (!(key in merged)) {
|
|
241
|
+
merged[key] = value;
|
|
242
|
+
} else if (value && typeof value === "object" && !Array.isArray(value) &&
|
|
243
|
+
merged[key] && typeof merged[key] === "object" && !Array.isArray(merged[key])) {
|
|
244
|
+
merged[key] = deepMergeDefaults(merged[key], value);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return merged as T;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function loadConfig(stateRoot: string): PlansConfig {
|
|
251
|
+
const configPath = path.join(stateRoot, "config.json");
|
|
252
|
+
if (!existsSync(configPath)) return structuredClone(DEFAULT_CONFIG);
|
|
253
|
+
let data: unknown;
|
|
254
|
+
try {
|
|
255
|
+
data = JSON.parse(readFileSync(configPath, "utf8"));
|
|
256
|
+
} catch (error) {
|
|
257
|
+
throw new StateError(`invalid config.json: ${(error as Error).message}`);
|
|
258
|
+
}
|
|
259
|
+
return normalizeArtifactRoot(deepMergeDefaults(data as PlansConfig, DEFAULT_CONFIG));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function noticeIfSubdir(workdir: string, notices: string[]): void {
|
|
263
|
+
const result = runGit(workdir, "rev-parse", "--show-toplevel");
|
|
264
|
+
if (result.code !== 0) return;
|
|
265
|
+
const top = path.resolve(result.stdout.trim());
|
|
266
|
+
if (top !== path.resolve(workdir)) {
|
|
267
|
+
notices.push(`storing state in enclosing repository ${top}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface EnsureResult {
|
|
272
|
+
config: PlansConfig;
|
|
273
|
+
stateRoot: string;
|
|
274
|
+
notices: string[];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ensureState(workdir: string): EnsureResult {
|
|
278
|
+
const notices: string[] = [];
|
|
279
|
+
const common = ensureGitRepo(workdir, notices);
|
|
280
|
+
const stateRoot = path.join(common, STATE_DIRNAME);
|
|
281
|
+
for (const sub of ["runs", "tmp", "cache"]) {
|
|
282
|
+
mkdirSync(path.join(stateRoot, sub), { recursive: true });
|
|
283
|
+
}
|
|
284
|
+
noticeIfSubdir(workdir, notices);
|
|
285
|
+
const config = loadConfig(stateRoot);
|
|
286
|
+
atomicWriteJson(path.join(stateRoot, "config.json"), config);
|
|
287
|
+
return { config, stateRoot, notices };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function initState(workdir: string): EnsureResult {
|
|
291
|
+
return ensureState(workdir);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Read-only config dump; never creates state. */
|
|
295
|
+
export function showConfig(workdir: string): PlansConfig {
|
|
296
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
297
|
+
if (stateRoot === null || !existsSync(path.join(stateRoot, "config.json"))) {
|
|
298
|
+
throw new StateError("no pi-plans state found; run the plans tool with action \"init\" first");
|
|
299
|
+
}
|
|
300
|
+
return loadConfig(stateRoot);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function setLanguage(workdir: string, tag: string, source: "user" | "auto"): EnsureResult {
|
|
304
|
+
const { config, stateRoot, notices } = ensureState(workdir);
|
|
305
|
+
config.language = { tag, source, updated_at: utcNow() };
|
|
306
|
+
atomicWriteJson(path.join(stateRoot, "config.json"), config);
|
|
307
|
+
return { config, stateRoot, notices };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function setArtifactRoot(workdir: string, artifactRoot: string, source: "user" | "auto"): EnsureResult {
|
|
311
|
+
const { config, stateRoot, notices } = ensureState(workdir);
|
|
312
|
+
config.artifact_root = artifactRoot;
|
|
313
|
+
config.artifact_root_source = source;
|
|
314
|
+
config.artifact_root_updated_at = utcNow();
|
|
315
|
+
atomicWriteJson(path.join(stateRoot, "config.json"), config);
|
|
316
|
+
return { config, stateRoot, notices };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface SetRoleOptions {
|
|
320
|
+
role: "reviewer" | "criticizer";
|
|
321
|
+
mode?: string;
|
|
322
|
+
modelSelector?: string; // exact "provider/model" selector, or "inherit" to reset
|
|
323
|
+
confirmed?: boolean;
|
|
324
|
+
resetConfirmation?: boolean;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function setRole(workdir: string, options: SetRoleOptions): EnsureResult {
|
|
328
|
+
if (options.mode !== undefined && !VALID_ROLE_MODES.has(options.mode)) {
|
|
329
|
+
throw new StateError(`mode must be one of ${[...VALID_ROLE_MODES].sort().join(", ")}`);
|
|
330
|
+
}
|
|
331
|
+
if (options.confirmed && options.resetConfirmation) {
|
|
332
|
+
throw new StateError("confirmed and resetConfirmation are mutually exclusive");
|
|
333
|
+
}
|
|
334
|
+
const { config, stateRoot, notices } = ensureState(workdir);
|
|
335
|
+
const role: RoleConfig = { ...DEFAULT_CONFIG[options.role], ...(config[options.role] as RoleConfig | undefined) };
|
|
336
|
+
if (options.mode !== undefined) role.mode = options.mode;
|
|
337
|
+
if (options.modelSelector !== undefined) {
|
|
338
|
+
role.model_selector = options.modelSelector === "inherit" ? null : options.modelSelector;
|
|
339
|
+
}
|
|
340
|
+
if (options.confirmed) role.confirmed_at = utcNow();
|
|
341
|
+
if (options.resetConfirmation) role.confirmed_at = null;
|
|
342
|
+
config[options.role] = role;
|
|
343
|
+
atomicWriteJson(path.join(stateRoot, "config.json"), config);
|
|
344
|
+
return { config, stateRoot, notices };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Runs
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
const SLUG_RE = /[^a-z0-9]+/g;
|
|
352
|
+
|
|
353
|
+
export function slugify(topic: string): string {
|
|
354
|
+
const slug = topic.toLowerCase().replace(SLUG_RE, "-").replace(/^-+|-+$/g, "");
|
|
355
|
+
return slug.slice(0, 80) || "planning-run";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface StartRunOptions {
|
|
359
|
+
topic: string;
|
|
360
|
+
skill: string;
|
|
361
|
+
requestText: string;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface StartRunResult {
|
|
365
|
+
run: RunInfo;
|
|
366
|
+
notices: string[];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function startRun(workdir: string, options: StartRunOptions): StartRunResult {
|
|
370
|
+
const { config, stateRoot, notices } = ensureState(workdir);
|
|
371
|
+
const now = utcNow();
|
|
372
|
+
const stamp = now.replace(/[-:]/g, "");
|
|
373
|
+
const topicSlug = slugify(options.topic);
|
|
374
|
+
const baseRunId = `${stamp}-${topicSlug}`;
|
|
375
|
+
let runId = baseRunId;
|
|
376
|
+
let suffix = 2;
|
|
377
|
+
while (existsSync(path.join(stateRoot, "runs", runId))) {
|
|
378
|
+
runId = `${baseRunId}-${suffix}`;
|
|
379
|
+
suffix += 1;
|
|
380
|
+
}
|
|
381
|
+
let artifactRoot = config.artifact_root ?? DEFAULT_ARTIFACT_ROOT;
|
|
382
|
+
if (!path.isAbsolute(artifactRoot)) artifactRoot = path.resolve(workdir, artifactRoot);
|
|
383
|
+
const dateSlug = now.slice(0, 10);
|
|
384
|
+
const artifactDir = path.join(artifactRoot, `${dateSlug}-${topicSlug}`);
|
|
385
|
+
const runDir = path.join(stateRoot, "runs", runId);
|
|
386
|
+
mkdirSync(runDir, { recursive: true });
|
|
387
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
388
|
+
const run: RunInfo = {
|
|
389
|
+
schema: 1,
|
|
390
|
+
run_id: runId,
|
|
391
|
+
skill: options.skill,
|
|
392
|
+
topic: topicSlug,
|
|
393
|
+
request_text: options.requestText,
|
|
394
|
+
workdir: path.resolve(workdir),
|
|
395
|
+
artifact_dir: artifactDir,
|
|
396
|
+
language_tag: config.language.tag ?? null,
|
|
397
|
+
status: "planning",
|
|
398
|
+
created_at: now,
|
|
399
|
+
updated_at: now,
|
|
400
|
+
};
|
|
401
|
+
atomicWriteJson(path.join(runDir, "run.json"), run);
|
|
402
|
+
for (const name of ["decisions.jsonl", "subagents.jsonl", "refs.jsonl"]) {
|
|
403
|
+
const ledger = path.join(runDir, name);
|
|
404
|
+
if (!existsSync(ledger)) writeFileSync(ledger, "", "utf8");
|
|
405
|
+
}
|
|
406
|
+
atomicWriteJson(path.join(stateRoot, "active.json"), {
|
|
407
|
+
run_id: runId,
|
|
408
|
+
run_dir: runDir,
|
|
409
|
+
artifact_dir: artifactDir,
|
|
410
|
+
} satisfies ActiveInfo);
|
|
411
|
+
return { run, notices };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Read the active run pointer; read-only, returns null when absent. */
|
|
415
|
+
export function readActive(workdir: string): ActiveInfo | null {
|
|
416
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
417
|
+
if (stateRoot === null) return null;
|
|
418
|
+
const activePath = path.join(stateRoot, "active.json");
|
|
419
|
+
if (!existsSync(activePath)) return null;
|
|
420
|
+
try {
|
|
421
|
+
return JSON.parse(readFileSync(activePath, "utf8")) as ActiveInfo;
|
|
422
|
+
} catch {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function getRun(workdir: string, runId: string): RunInfo | null {
|
|
428
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
429
|
+
if (stateRoot === null) return null;
|
|
430
|
+
const runPath = path.join(stateRoot, "runs", runId, "run.json");
|
|
431
|
+
if (!existsSync(runPath)) return null;
|
|
432
|
+
try {
|
|
433
|
+
return JSON.parse(readFileSync(runPath, "utf8")) as RunInfo;
|
|
434
|
+
} catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function appendJsonl(filePath: string, entry: unknown): void {
|
|
440
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
441
|
+
fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function requireRunDir(workdir: string, runId: string): string {
|
|
445
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
446
|
+
if (stateRoot === null) throw new StateError("no pi-plans state found; run init first");
|
|
447
|
+
const runDir = path.join(stateRoot, "runs", runId);
|
|
448
|
+
if (!existsSync(runDir)) throw new StateError(`run does not exist: ${runId}`);
|
|
449
|
+
return runDir;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function recordDecision(workdir: string, runId: string, entry: Omit<DecisionEntry, "recorded_at">): DecisionEntry {
|
|
453
|
+
const runDir = requireRunDir(workdir, runId);
|
|
454
|
+
const full: DecisionEntry = { ...entry, recorded_at: utcNow() };
|
|
455
|
+
appendJsonl(path.join(runDir, "decisions.jsonl"), full);
|
|
456
|
+
return full;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function recordRef(workdir: string, runId: string, entry: Omit<RefEntry, "recorded_at">): RefEntry {
|
|
460
|
+
const runDir = requireRunDir(workdir, runId);
|
|
461
|
+
const full: RefEntry = { ...entry, recorded_at: utcNow() };
|
|
462
|
+
appendJsonl(path.join(runDir, "refs.jsonl"), full);
|
|
463
|
+
return full;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function recordSubagent(workdir: string, runId: string, entry: Omit<SubagentEntry, "recorded_at">): SubagentEntry {
|
|
467
|
+
const runDir = requireRunDir(workdir, runId);
|
|
468
|
+
const full: SubagentEntry = { ...entry, recorded_at: utcNow() };
|
|
469
|
+
appendJsonl(path.join(runDir, "subagents.jsonl"), full);
|
|
470
|
+
return full;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function setRunStatus(workdir: string, runId: string, status: string): RunInfo {
|
|
474
|
+
if (!VALID_RUN_STATUSES.has(status)) {
|
|
475
|
+
throw new StateError(`status must be one of ${[...VALID_RUN_STATUSES].join(", ")}`);
|
|
476
|
+
}
|
|
477
|
+
const stateRoot = resolveStateRootOrNull(workdir);
|
|
478
|
+
if (stateRoot === null) throw new StateError("no pi-plans state found; run init first");
|
|
479
|
+
const runPath = path.join(stateRoot, "runs", runId, "run.json");
|
|
480
|
+
if (!existsSync(runPath)) throw new StateError(`run does not exist: ${runId}`);
|
|
481
|
+
const run = JSON.parse(readFileSync(runPath, "utf8")) as RunInfo;
|
|
482
|
+
run.status = status;
|
|
483
|
+
run.updated_at = utcNow();
|
|
484
|
+
atomicWriteJson(runPath, run);
|
|
485
|
+
return run;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function refsCacheDir(): string {
|
|
489
|
+
return path.join(os.homedir(), ".cache", "pi-plans", "refs");
|
|
490
|
+
}
|
package/src/subagent.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal read-only subagent runner: spawns a `pi --mode json -p --no-session`
|
|
3
|
+
* subprocess with a delegated system prompt and restricted tools, mirrors the
|
|
4
|
+
* official subagent example's invocation and JSON event parsing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface SubagentOptions {
|
|
13
|
+
systemPrompt: string;
|
|
14
|
+
task: string;
|
|
15
|
+
cwd: string;
|
|
16
|
+
/** Exact "provider/model" selector; omit to inherit the dispatching session's model. */
|
|
17
|
+
model?: string;
|
|
18
|
+
/** Tool allowlist for the child process. Defaults to read-only tools. */
|
|
19
|
+
tools?: string[];
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SubagentResult {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
output: string;
|
|
27
|
+
model?: string;
|
|
28
|
+
errorMessage?: string;
|
|
29
|
+
stderr: string;
|
|
30
|
+
turns: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Strip YAML frontmatter from an agent definition file. */
|
|
34
|
+
export function stripFrontmatter(text: string): string {
|
|
35
|
+
if (!text.startsWith("---\n")) return text;
|
|
36
|
+
const end = text.indexOf("\n---\n", 3);
|
|
37
|
+
if (end < 0) return text;
|
|
38
|
+
return text.slice(end + 5).trimStart();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
42
|
+
const currentScript = process.argv[1];
|
|
43
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
44
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
45
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
46
|
+
}
|
|
47
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
48
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
49
|
+
if (!isGenericRuntime) {
|
|
50
|
+
return { command: process.execPath, args };
|
|
51
|
+
}
|
|
52
|
+
return { command: "pi", args };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface MessageLike {
|
|
56
|
+
role: string;
|
|
57
|
+
content: Array<{ type: string; text?: string }>;
|
|
58
|
+
model?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function finalOutput(messages: MessageLike[]): string {
|
|
62
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
63
|
+
const message = messages[i];
|
|
64
|
+
if (message.role === "assistant") {
|
|
65
|
+
const text = message.content
|
|
66
|
+
.filter((part) => part.type === "text")
|
|
67
|
+
.map((part) => part.text ?? "")
|
|
68
|
+
.join("\n")
|
|
69
|
+
.trim();
|
|
70
|
+
if (text) return text;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
77
|
+
|
|
78
|
+
export async function runPiSubagent(options: SubagentOptions): Promise<SubagentResult> {
|
|
79
|
+
const tools = options.tools ?? ["read", "grep", "find", "ls"];
|
|
80
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-subagent-"));
|
|
81
|
+
const promptFile = path.join(tmpDir, "system-prompt.md");
|
|
82
|
+
fs.writeFileSync(promptFile, options.systemPrompt, { encoding: "utf8", mode: 0o600 });
|
|
83
|
+
|
|
84
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session", "--tools", tools.join(",")];
|
|
85
|
+
if (options.model) args.push("--model", options.model);
|
|
86
|
+
args.push("--append-system-prompt", promptFile);
|
|
87
|
+
args.push(`Task: ${options.task}`);
|
|
88
|
+
|
|
89
|
+
const messages: MessageLike[] = [];
|
|
90
|
+
let stderr = "";
|
|
91
|
+
let wasAborted = false;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
95
|
+
const invocation = getPiInvocation(args);
|
|
96
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
97
|
+
cwd: options.cwd,
|
|
98
|
+
shell: false,
|
|
99
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
100
|
+
});
|
|
101
|
+
let buffer = "";
|
|
102
|
+
|
|
103
|
+
const processLine = (line: string) => {
|
|
104
|
+
if (!line.trim()) return;
|
|
105
|
+
let event: { type?: string; message?: MessageLike };
|
|
106
|
+
try {
|
|
107
|
+
event = JSON.parse(line);
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if ((event.type === "message_end" || event.type === "tool_result_end") && event.message) {
|
|
112
|
+
messages.push(event.message);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
proc.stdout.on("data", (data) => {
|
|
117
|
+
buffer += data.toString();
|
|
118
|
+
const lines = buffer.split("\n");
|
|
119
|
+
buffer = lines.pop() ?? "";
|
|
120
|
+
for (const line of lines) processLine(line);
|
|
121
|
+
});
|
|
122
|
+
proc.stderr.on("data", (data) => {
|
|
123
|
+
stderr += data.toString();
|
|
124
|
+
});
|
|
125
|
+
proc.on("close", (code) => {
|
|
126
|
+
if (buffer.trim()) processLine(buffer);
|
|
127
|
+
resolve(code ?? 0);
|
|
128
|
+
});
|
|
129
|
+
proc.on("error", () => resolve(1));
|
|
130
|
+
|
|
131
|
+
const killProc = () => {
|
|
132
|
+
wasAborted = true;
|
|
133
|
+
proc.kill("SIGTERM");
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
try {
|
|
136
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
137
|
+
} catch {
|
|
138
|
+
/* already gone */
|
|
139
|
+
}
|
|
140
|
+
}, 5000);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const timer = setTimeout(killProc, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
144
|
+
const onAbort = () => killProc();
|
|
145
|
+
if (options.signal) {
|
|
146
|
+
if (options.signal.aborted) killProc();
|
|
147
|
+
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
148
|
+
}
|
|
149
|
+
proc.on("close", () => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (wasAborted) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
output: finalOutput(messages),
|
|
159
|
+
stderr,
|
|
160
|
+
turns: messages.filter((m) => m.role === "assistant").length,
|
|
161
|
+
errorMessage: "Subagent was aborted",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (exitCode !== 0) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
output: finalOutput(messages),
|
|
168
|
+
stderr,
|
|
169
|
+
turns: messages.filter((m) => m.role === "assistant").length,
|
|
170
|
+
errorMessage: `pi exited with code ${exitCode}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const output = finalOutput(messages);
|
|
174
|
+
if (!output) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
output: "",
|
|
178
|
+
stderr,
|
|
179
|
+
turns: messages.filter((m) => m.role === "assistant").length,
|
|
180
|
+
errorMessage: "subagent produced no final output",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
output,
|
|
186
|
+
model: [...messages].reverse().find((m) => m.role === "assistant" && m.model)?.model,
|
|
187
|
+
stderr,
|
|
188
|
+
turns: messages.filter((m) => m.role === "assistant").length,
|
|
189
|
+
};
|
|
190
|
+
} finally {
|
|
191
|
+
try {
|
|
192
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
193
|
+
} catch {
|
|
194
|
+
/* best effort */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|