@ilya-lesikov/pi-pi 0.5.0 → 0.7.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/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +68 -28
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +631 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +7 -3
- package/extensions/orchestrator/doctor.test.ts +561 -0
- package/extensions/orchestrator/doctor.ts +702 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1220 -343
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +511 -0
- package/extensions/orchestrator/flant-infra.ts +390 -49
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +3065 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +302 -0
- package/extensions/orchestrator/model-registry.ts +298 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +298 -140
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -36
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -31
- package/extensions/orchestrator/phases/review-task.ts +3 -7
- package/extensions/orchestrator/phases/review.test.ts +54 -0
- package/extensions/orchestrator/phases/review.ts +89 -48
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +207 -0
- package/extensions/orchestrator/pp-menu.ts +2741 -373
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +128 -44
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +229 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +563 -0
- package/extensions/orchestrator/usage-tracker.ts +96 -12
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- package/package.json +2 -1
|
@@ -1,38 +1,63 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join, basename, resolve } from "path";
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
|
|
2
|
+
import { join, basename, resolve, relative, isAbsolute, sep } from "path";
|
|
3
3
|
import lockfile from "proper-lockfile";
|
|
4
|
+
import { normalizeRepoPath, type RepoInfo } from "./repo-utils.js";
|
|
5
|
+
import { getLogger } from "./log.js";
|
|
4
6
|
|
|
5
7
|
function getLockfileFs(): typeof import("fs") | undefined {
|
|
6
8
|
try {
|
|
7
9
|
const fs = require("fs");
|
|
8
10
|
fs.statSync(process.cwd());
|
|
9
11
|
return fs;
|
|
10
|
-
} catch {
|
|
12
|
+
} catch (err: any) {
|
|
13
|
+
getLogger().debug({ s: "state", err: err?.message }, "getLockfileFs unavailable");
|
|
11
14
|
return undefined;
|
|
12
15
|
}
|
|
13
16
|
}
|
|
14
17
|
import type { TimeoutConfig } from "./config.js";
|
|
15
18
|
|
|
16
|
-
export type
|
|
19
|
+
export type TaskMode = "guided" | "autonomous";
|
|
20
|
+
|
|
21
|
+
export interface AutonomousPhaseConfig {
|
|
22
|
+
plannerPreset?: string;
|
|
23
|
+
reviewPreset?: string;
|
|
24
|
+
maxReviewPasses: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AutonomousConfig {
|
|
28
|
+
phases: Record<string, AutonomousPhaseConfig>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type TaskType = "implement" | "debug" | "brainstorm" | "review" | "quick";
|
|
17
32
|
|
|
18
33
|
export type ImplementPhase = "brainstorm" | "plan" | "implement" | "done";
|
|
19
34
|
export type DebugPhase = "debug" | "plan" | "implement" | "done";
|
|
20
35
|
export type BrainstormPhase = "brainstorm" | "plan" | "implement" | "done";
|
|
21
36
|
export type ReviewPhase = "review" | "plan" | "implement" | "done";
|
|
22
|
-
export type
|
|
37
|
+
export type QuickPhase = "quick";
|
|
38
|
+
export type Phase = ImplementPhase | DebugPhase | BrainstormPhase | ReviewPhase | QuickPhase;
|
|
23
39
|
|
|
24
40
|
export interface TaskState {
|
|
25
41
|
phase: Phase;
|
|
42
|
+
initialPhase?: string;
|
|
26
43
|
step: string | null;
|
|
27
44
|
reviewCycle: { kind: string; step: string; pass: number } | null;
|
|
28
45
|
reviewPass: number;
|
|
29
|
-
reviewPassByKind?: Record<string, number
|
|
46
|
+
reviewPassByKind?: Record<string, Record<string, number>>;
|
|
47
|
+
reviewApprovedClean?: boolean;
|
|
30
48
|
modifiedFiles?: string[];
|
|
31
|
-
|
|
49
|
+
committedFiles?: string[];
|
|
50
|
+
repos?: RepoInfo[];
|
|
32
51
|
from: string | null;
|
|
33
52
|
description: string;
|
|
34
53
|
startedAt: string;
|
|
35
|
-
|
|
54
|
+
activePlannerPreset?: string;
|
|
55
|
+
activeReviewPreset?: string;
|
|
56
|
+
mode?: TaskMode;
|
|
57
|
+
effectiveMode?: TaskMode;
|
|
58
|
+
autonomousConfig?: AutonomousConfig;
|
|
59
|
+
plannerFailureAutoRetried?: boolean;
|
|
60
|
+
reviewerFailureAutoRetried?: boolean;
|
|
36
61
|
}
|
|
37
62
|
|
|
38
63
|
export interface TaskInfo {
|
|
@@ -49,7 +74,27 @@ function taskStatePath(taskDir: string): string {
|
|
|
49
74
|
return join(taskDir, "state.json");
|
|
50
75
|
}
|
|
51
76
|
|
|
52
|
-
export function
|
|
77
|
+
export function getFirstPhase(type: TaskType): Exclude<Phase, "done"> {
|
|
78
|
+
if (type === "implement" || type === "brainstorm") return "brainstorm";
|
|
79
|
+
if (type === "debug") return "debug";
|
|
80
|
+
if (type === "review") return "review";
|
|
81
|
+
return "quick";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getEffectiveMode(state: TaskState): TaskMode | undefined {
|
|
85
|
+
return state.effectiveMode ?? state.mode;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The research first phases (brainstorm/debug/review) are always interactive: the user
|
|
89
|
+
// drives them, so they never inherit the task's autonomous mode even when one is set.
|
|
90
|
+
// plan/implement are autonomous-capable regardless of whether they are the task's initial phase.
|
|
91
|
+
export function getEffectivePhaseMode(state: TaskState): TaskMode {
|
|
92
|
+
if (state.phase === "brainstorm" || state.phase === "debug" || state.phase === "review") return "guided";
|
|
93
|
+
return getEffectiveMode(state) ?? "guided";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createTask(cwd: string, type: TaskType, description: string, mode?: TaskMode): string {
|
|
97
|
+
const log = getLogger();
|
|
53
98
|
const id = crypto.randomUUID().slice(0, 12);
|
|
54
99
|
const safeName = description
|
|
55
100
|
.toLowerCase()
|
|
@@ -62,16 +107,22 @@ export function createTask(cwd: string, type: TaskType, description: string): st
|
|
|
62
107
|
mkdirSync(taskDir, { recursive: true });
|
|
63
108
|
|
|
64
109
|
const state: TaskState = {
|
|
65
|
-
phase: type
|
|
110
|
+
phase: getFirstPhase(type),
|
|
111
|
+
initialPhase: getFirstPhase(type),
|
|
66
112
|
step: "llm_work",
|
|
67
113
|
reviewCycle: null,
|
|
68
114
|
reviewPass: 0,
|
|
115
|
+
repos: [{ path: normalizeRepoPath(cwd), isRoot: true }],
|
|
69
116
|
from: null,
|
|
70
117
|
description,
|
|
71
118
|
startedAt: new Date().toISOString(),
|
|
119
|
+
mode,
|
|
120
|
+
plannerFailureAutoRetried: false,
|
|
121
|
+
reviewerFailureAutoRetried: false,
|
|
72
122
|
};
|
|
73
123
|
|
|
74
124
|
writeFileSync(taskStatePath(taskDir), JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
125
|
+
log.info({ s: "task", taskDir, type, description, phase: state.phase }, "task created");
|
|
75
126
|
return taskDir;
|
|
76
127
|
}
|
|
77
128
|
|
|
@@ -80,6 +131,23 @@ export function loadTask(taskDir: string): TaskState {
|
|
|
80
131
|
const raw = readFileSync(sp, "utf-8");
|
|
81
132
|
try {
|
|
82
133
|
const state = JSON.parse(raw) as TaskState;
|
|
134
|
+
if ((state as any).phase === "planning") {
|
|
135
|
+
state.phase = "plan";
|
|
136
|
+
}
|
|
137
|
+
if (!state.initialPhase) {
|
|
138
|
+
state.initialPhase = state.phase;
|
|
139
|
+
}
|
|
140
|
+
if (state.plannerFailureAutoRetried === undefined) {
|
|
141
|
+
state.plannerFailureAutoRetried = false;
|
|
142
|
+
}
|
|
143
|
+
if (state.reviewerFailureAutoRetried === undefined) {
|
|
144
|
+
state.reviewerFailureAutoRetried = false;
|
|
145
|
+
}
|
|
146
|
+
if ((state as any).repoCwd && (!state.repos || state.repos.length === 0)) {
|
|
147
|
+
state.repos = [{ path: (state as any).repoCwd, isRoot: false }];
|
|
148
|
+
}
|
|
149
|
+
if (!state.repos) state.repos = [];
|
|
150
|
+
delete (state as any).repoCwd;
|
|
83
151
|
return state;
|
|
84
152
|
} catch (err: any) {
|
|
85
153
|
throw new Error(`Failed to parse ${sp}: ${err.message}`);
|
|
@@ -87,6 +155,7 @@ export function loadTask(taskDir: string): TaskState {
|
|
|
87
155
|
}
|
|
88
156
|
|
|
89
157
|
export function saveTask(taskDir: string, state: TaskState): void {
|
|
158
|
+
getLogger().debug({ s: "task", taskDir, phase: state.phase, step: state.step }, "saving task state");
|
|
90
159
|
writeFileSync(taskStatePath(taskDir), JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
91
160
|
}
|
|
92
161
|
|
|
@@ -94,7 +163,7 @@ export function listTasks(cwd: string, type?: TaskType): TaskInfo[] {
|
|
|
94
163
|
const base = stateDir(cwd);
|
|
95
164
|
if (!existsSync(base)) return [];
|
|
96
165
|
|
|
97
|
-
const types: TaskType[] = type ? [type] : ["implement", "debug", "brainstorm", "review"];
|
|
166
|
+
const types: TaskType[] = type ? [type] : ["implement", "debug", "brainstorm", "review", "quick"];
|
|
98
167
|
const results: TaskInfo[] = [];
|
|
99
168
|
|
|
100
169
|
for (const t of types) {
|
|
@@ -113,7 +182,7 @@ export function listTasks(cwd: string, type?: TaskType): TaskInfo[] {
|
|
|
113
182
|
results.push({ dir, state, type: t });
|
|
114
183
|
}
|
|
115
184
|
} catch {
|
|
116
|
-
|
|
185
|
+
getLogger().warn({ s: "state", dir }, "skipping corrupt task");
|
|
117
186
|
}
|
|
118
187
|
}
|
|
119
188
|
}
|
|
@@ -135,11 +204,11 @@ export async function lockTask(taskDir: string, timeouts: TimeoutConfig): Promis
|
|
|
135
204
|
const lockFs = getLockfileFs();
|
|
136
205
|
const release = await lockfile.lock(sp, {
|
|
137
206
|
...(lockFs && { fs: lockFs }),
|
|
138
|
-
stale: timeouts.
|
|
139
|
-
update: timeouts.
|
|
207
|
+
stale: timeouts.taskLockStale,
|
|
208
|
+
update: timeouts.taskLockRefresh,
|
|
140
209
|
retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 },
|
|
141
210
|
onCompromised: (err: Error) => {
|
|
142
|
-
|
|
211
|
+
getLogger().error({ s: "state", path: sp, err: err.message }, "lock compromised");
|
|
143
212
|
},
|
|
144
213
|
});
|
|
145
214
|
return release;
|
|
@@ -158,7 +227,7 @@ export function getActiveTask(cwd: string, lockStale?: number): TaskInfo | null
|
|
|
158
227
|
unlocked.push(task);
|
|
159
228
|
}
|
|
160
229
|
} catch {
|
|
161
|
-
|
|
230
|
+
getLogger().warn({ s: "state", dir: task.dir }, "failed to check lock");
|
|
162
231
|
}
|
|
163
232
|
}
|
|
164
233
|
|
|
@@ -173,8 +242,9 @@ export function validateFromPath(cwd: string, fromPath: string): { ok: true; dir
|
|
|
173
242
|
|
|
174
243
|
const stateRoot = resolve(stateDir(cwd));
|
|
175
244
|
const resolved = resolve(stateRoot, fromPath);
|
|
245
|
+
const rel = relative(stateRoot, resolved);
|
|
176
246
|
|
|
177
|
-
if (
|
|
247
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
178
248
|
return { ok: false, reason: "Path escapes .pp/state/ directory" };
|
|
179
249
|
}
|
|
180
250
|
|
|
@@ -192,30 +262,51 @@ export function validateFromPath(cwd: string, fromPath: string): { ok: true; dir
|
|
|
192
262
|
|
|
193
263
|
export function taskName(taskDir: string): string {
|
|
194
264
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const content = readFileSync(urPath, "utf-8");
|
|
202
|
-
const lines = content.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
203
|
-
const firstContent = lines.find((l) => !l.startsWith("#"));
|
|
204
|
-
if (firstContent) desc = firstContent;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
265
|
+
return taskNameFromState(taskDir, loadTask(taskDir));
|
|
266
|
+
} catch {
|
|
267
|
+
getLogger().warn({ s: "state", taskDir }, "failed to read task name");
|
|
268
|
+
return dirSlugName(taskDir);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
207
271
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
272
|
+
// Same as taskName but reuses an already-loaded TaskState (avoids re-reading
|
|
273
|
+
// state.json per resume-menu entry). The only per-entry disk read is the
|
|
274
|
+
// USER_REQUEST.md fallback, which is unavoidable for generic descriptions.
|
|
275
|
+
export function taskNameFromState(taskDir: string, state: TaskState): string {
|
|
276
|
+
let desc = state.description ?? "";
|
|
277
|
+
|
|
278
|
+
if (["implement", "debug", "brainstorm", "review", "quick"].includes(desc)) {
|
|
279
|
+
const urPath = join(taskDir, "USER_REQUEST.md");
|
|
280
|
+
if (existsSync(urPath)) {
|
|
281
|
+
const content = readFileSync(urPath, "utf-8");
|
|
282
|
+
const lines = content.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
283
|
+
const firstContent = lines.find((l) => !l.startsWith("#"));
|
|
284
|
+
if (firstContent) desc = firstContent;
|
|
212
285
|
}
|
|
213
|
-
} catch {
|
|
214
|
-
console.error(`[pi-pi] Failed to read task name from ${taskDir}`);
|
|
215
286
|
}
|
|
287
|
+
|
|
288
|
+
if (desc) {
|
|
289
|
+
desc = desc.replace(/\s+/g, " ").trim();
|
|
290
|
+
if (desc.length > 60) desc = desc.slice(0, 57) + "...";
|
|
291
|
+
return desc;
|
|
292
|
+
}
|
|
293
|
+
return dirSlugName(taskDir);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function dirSlugName(taskDir: string): string {
|
|
297
|
+
const dir = basename(taskDir);
|
|
298
|
+
const idx = dir.indexOf("_");
|
|
299
|
+
const slug = idx >= 0 ? dir.slice(idx + 1) : dir;
|
|
300
|
+
return slug.replace(/-/g, " ");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The task's short id is the leading segment of its dir name (`${id}_${slug}`),
|
|
304
|
+
// where id is a 12-char uuid slice from createTask. Used to disambiguate
|
|
305
|
+
// otherwise-identical resume-menu titles.
|
|
306
|
+
export function taskShortId(taskDir: string): string {
|
|
216
307
|
const dir = basename(taskDir);
|
|
217
|
-
const
|
|
218
|
-
return
|
|
308
|
+
const idPart = dir.split("_", 1)[0] ?? dir;
|
|
309
|
+
return idPart.slice(0, 6);
|
|
219
310
|
}
|
|
220
311
|
|
|
221
312
|
export function taskAge(state: TaskState): string {
|
|
@@ -227,10 +318,3 @@ export function taskAge(state: TaskState): string {
|
|
|
227
318
|
if (hours < 24) return `${hours}h`;
|
|
228
319
|
return `${Math.floor(hours / 24)}d`;
|
|
229
320
|
}
|
|
230
|
-
|
|
231
|
-
export function appendTaskLog(taskDir: string, fileName: string, entry: Record<string, unknown>): string {
|
|
232
|
-
mkdirSync(taskDir, { recursive: true });
|
|
233
|
-
const filePath = join(taskDir, fileName);
|
|
234
|
-
appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
|
|
235
|
-
return filePath;
|
|
236
|
-
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Documents the shared contract for Symbol.for("pi-pi:subagent-session") between
|
|
4
|
+
// extensions/orchestrator/index.ts (writer) and 3p/pi-subagents/src/agent-runner.ts
|
|
5
|
+
// (depth tracker). The canonical shape is { depth: number }; a legacy boolean true
|
|
6
|
+
// must still be tolerated as depth 1. This mirrors agent-runner's read so a future
|
|
7
|
+
// shape change on either side is caught.
|
|
8
|
+
function readDepth(marker: unknown): number {
|
|
9
|
+
return typeof marker === "object" && marker !== null
|
|
10
|
+
? ((marker as { depth?: number }).depth ?? 0)
|
|
11
|
+
: marker
|
|
12
|
+
? 1
|
|
13
|
+
: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("subagent-session marker contract", () => {
|
|
17
|
+
it("treats the orchestrator's { depth: 1 } write as depth 1 (truthy, not reset to 0)", () => {
|
|
18
|
+
const marker = { depth: 1 };
|
|
19
|
+
expect(readDepth(marker)).toBe(1);
|
|
20
|
+
expect(Boolean(marker)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("still tolerates a legacy boolean marker as depth 1", () => {
|
|
24
|
+
expect(readDepth(true)).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("reports depth 0 when no marker is set", () => {
|
|
28
|
+
expect(readDepth(undefined)).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("increments nesting from an existing object marker", () => {
|
|
32
|
+
const previous = { depth: 2 };
|
|
33
|
+
const next = { depth: readDepth(previous) + 1 };
|
|
34
|
+
expect(next.depth).toBe(3);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
export type TextMatch = string | RegExp | ((value: string) => boolean);
|
|
4
|
+
|
|
5
|
+
export interface MenuExpectation {
|
|
6
|
+
question?: TextMatch;
|
|
7
|
+
options?: {
|
|
8
|
+
exact?: TextMatch[];
|
|
9
|
+
include?: TextMatch[];
|
|
10
|
+
exclude?: TextMatch[];
|
|
11
|
+
};
|
|
12
|
+
// When set, the harness returns a cancel (as askUser/isCancel would on ESC)
|
|
13
|
+
// instead of choosing an option. `choose` is ignored in that case.
|
|
14
|
+
cancel?: "user" | "timeout" | "signal";
|
|
15
|
+
choose?: TextMatch | ((options: string[]) => string);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MenuTranscriptEntry {
|
|
19
|
+
question: string;
|
|
20
|
+
options: Array<{ title: string; description?: string }>;
|
|
21
|
+
chosen: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AskUserHarness {
|
|
25
|
+
transcript: MenuTranscriptEntry[];
|
|
26
|
+
expect(step: MenuExpectation): AskUserHarness;
|
|
27
|
+
handle(opts: any): Promise<{ kind: "selection"; selections: [string] } | { __cancel: true; reason: "user" | "timeout" | "signal" }>;
|
|
28
|
+
assertDone(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface MenuOption {
|
|
32
|
+
title: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function escapeRegExp(value: string): string {
|
|
37
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stringifyMatcher(matcher: TextMatch | undefined): string {
|
|
41
|
+
if (matcher === undefined) return "<none>";
|
|
42
|
+
if (typeof matcher === "string") return JSON.stringify(matcher);
|
|
43
|
+
if (matcher instanceof RegExp) return matcher.toString();
|
|
44
|
+
return "<function>";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function matchText(matcher: TextMatch, value: string): boolean {
|
|
48
|
+
if (typeof matcher === "string") return value === matcher;
|
|
49
|
+
if (matcher instanceof RegExp) return matcher.test(value);
|
|
50
|
+
return matcher(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderMenu(question: string, options: MenuOption[]): string {
|
|
54
|
+
const lines = options.map((option) => {
|
|
55
|
+
if (!option.description) return `- ${option.title}`;
|
|
56
|
+
return `- ${option.title} — ${option.description}`;
|
|
57
|
+
});
|
|
58
|
+
return `question=${JSON.stringify(question)}\noptions:\n${lines.join("\n")}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createAskUserHarness(): AskUserHarness {
|
|
62
|
+
const queue: MenuExpectation[] = [];
|
|
63
|
+
const transcript: MenuTranscriptEntry[] = [];
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
transcript,
|
|
67
|
+
expect(step: MenuExpectation) {
|
|
68
|
+
queue.push(step);
|
|
69
|
+
return this;
|
|
70
|
+
},
|
|
71
|
+
async handle(opts: any) {
|
|
72
|
+
const question = String(opts?.question ?? "");
|
|
73
|
+
const options: MenuOption[] = (Array.isArray(opts?.options) ? opts.options : []).map((option: any): MenuOption => {
|
|
74
|
+
if (typeof option === "string") return { title: option };
|
|
75
|
+
return {
|
|
76
|
+
title: String(option?.title ?? ""),
|
|
77
|
+
description: option?.description === undefined ? undefined : String(option.description),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
const titles: string[] = options.map((option: MenuOption) => option.title);
|
|
81
|
+
|
|
82
|
+
if (queue.length === 0) {
|
|
83
|
+
throw new Error(`Unexpected menu: ${renderMenu(question, options)}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const expected = queue.shift()!;
|
|
87
|
+
|
|
88
|
+
if (expected.question && !matchText(expected.question, question)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Question mismatch: expected ${stringifyMatcher(expected.question)}, actual ${JSON.stringify(question)}\n${renderMenu(question, options)}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (expected.options?.include) {
|
|
95
|
+
for (const include of expected.options.include) {
|
|
96
|
+
const found = titles.some((title: string) => matchText(include, title));
|
|
97
|
+
if (!found) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Missing expected option ${stringifyMatcher(include)}\n${renderMenu(question, options)}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (expected.options?.exclude) {
|
|
106
|
+
for (const exclude of expected.options.exclude) {
|
|
107
|
+
const found = titles.some((title: string) => matchText(exclude, title));
|
|
108
|
+
if (found) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Found excluded option ${stringifyMatcher(exclude)}\n${renderMenu(question, options)}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (expected.options?.exact) {
|
|
117
|
+
expect(titles.length).toBe(expected.options.exact.length);
|
|
118
|
+
for (let i = 0; i < expected.options.exact.length; i += 1) {
|
|
119
|
+
const matcher = expected.options.exact[i]!;
|
|
120
|
+
const actual = titles[i] ?? "";
|
|
121
|
+
if (!matchText(matcher, actual)) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Exact options mismatch at index ${i}: expected ${stringifyMatcher(matcher)}, actual ${JSON.stringify(actual)}\n${renderMenu(question, options)}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (expected.cancel) {
|
|
130
|
+
transcript.push({ question, options, chosen: `<cancel:${expected.cancel}>` });
|
|
131
|
+
return { __cancel: true, reason: expected.cancel };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (expected.choose === undefined) {
|
|
135
|
+
throw new Error(`Menu expectation is missing a chooser\n${renderMenu(question, options)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let chosen: string;
|
|
139
|
+
if (typeof expected.choose === "function") {
|
|
140
|
+
const maybeChoice = (expected.choose as (options: string[]) => unknown)(titles);
|
|
141
|
+
if (typeof maybeChoice === "string") {
|
|
142
|
+
chosen = maybeChoice;
|
|
143
|
+
} else {
|
|
144
|
+
const matcher = expected.choose as (value: string) => boolean;
|
|
145
|
+
const picked = titles.find((title: string) => matcher(title));
|
|
146
|
+
if (!picked) {
|
|
147
|
+
throw new Error(`No option matched chooser function\n${renderMenu(question, options)}`);
|
|
148
|
+
}
|
|
149
|
+
chosen = picked;
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
const picked = titles.find((title: string) => matchText(expected.choose as TextMatch, title));
|
|
153
|
+
if (!picked) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`No option matched chooser ${stringifyMatcher(expected.choose as TextMatch)}\n${renderMenu(question, options)}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
chosen = picked;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!titles.includes(chosen)) {
|
|
162
|
+
throw new Error(`Chosen option not found: ${JSON.stringify(chosen)}\n${renderMenu(question, options)}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
transcript.push({ question, options, chosen });
|
|
166
|
+
|
|
167
|
+
return { kind: "selection", selections: [chosen] };
|
|
168
|
+
},
|
|
169
|
+
assertDone() {
|
|
170
|
+
if (queue.length > 0) {
|
|
171
|
+
const next = queue[0]!;
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Unconsumed menu expectations: ${queue.length}. Next expected question=${stringifyMatcher(next.question)} choose=${stringifyMatcher(
|
|
174
|
+
typeof next.choose === "function" ? undefined : next.choose,
|
|
175
|
+
)}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const m = {
|
|
183
|
+
taskMenu: (type: string, phase: string) =>
|
|
184
|
+
(q: string) => q.includes(`Task: ${type}`) && q.includes(`Phase: ${phase}`),
|
|
185
|
+
anyTaskMenu: (q: string) => q.startsWith("/pp"),
|
|
186
|
+
preset: (name: string) => new RegExp(`^${escapeRegExp(name)}(?: \\[default\\])?$`),
|
|
187
|
+
autoReview: /^Auto review(?: \(pass \d+\))?$/,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export function expectActiveTaskNext(menu: AskUserHarness, choose: string): void {
|
|
191
|
+
menu.expect({ question: m.anyTaskMenu, options: { include: ["Next"] }, choose: "Next" });
|
|
192
|
+
menu.expect({ question: "Next", options: { include: [choose] }, choose });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function expectBrainstormToPlan(menu: AskUserHarness): void {
|
|
196
|
+
expectActiveTaskNext(menu, "Continue to plan & implement");
|
|
197
|
+
menu.expect({ question: "Planner preset", options: { include: [m.preset("regular"), "Back"] }, choose: m.preset("regular") });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function expectPlanToImplement(menu: AskUserHarness): void {
|
|
201
|
+
expectActiveTaskNext(menu, "Continue to implement");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function expectImplementToDone(menu: AskUserHarness): void {
|
|
205
|
+
expectActiveTaskNext(menu, "Complete");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function expectQuickMenu(menu: AskUserHarness, choose: string): void {
|
|
209
|
+
menu.expect({ question: m.anyTaskMenu, options: { include: [choose] }, choose });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function expectReviewAuto(menu: AskUserHarness, preset = "regular"): void {
|
|
213
|
+
menu.expect({ question: m.anyTaskMenu, options: { include: ["Review"] }, choose: "Review" });
|
|
214
|
+
menu.expect({ question: "Review", options: { include: [m.autoReview] }, choose: m.autoReview });
|
|
215
|
+
menu.expect({ question: "Review preset", options: { include: [m.preset(preset), "Back"] }, choose: m.preset(preset) });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function expectReviewOnMyOwn(menu: AskUserHarness): void {
|
|
219
|
+
menu.expect({ question: m.anyTaskMenu, options: { include: ["Review"] }, choose: "Review" });
|
|
220
|
+
menu.expect({ question: "Review", options: { include: ["Review on my own"] }, choose: "Review on my own" });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function expectPpStartImplementAutonomous(menu: AskUserHarness): void {
|
|
224
|
+
menu.expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" });
|
|
225
|
+
menu.expect({ question: "Task", options: { include: ["Implement"] }, choose: "Implement" });
|
|
226
|
+
menu.expect({ question: "Implement", options: { include: ["New"] }, choose: "New" });
|
|
227
|
+
menu.expect({ question: "Mode", options: { include: ["Guided", "Autonomous", "Back"] }, choose: "Autonomous" });
|
|
228
|
+
menu.expect({ question: "Autonomous", options: { include: ["Start", "Back"] }, choose: "Start" });
|
|
229
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, utimesSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { initTracer, finalizeTracer, getTracer } from "./tracer.js";
|
|
6
|
+
|
|
7
|
+
const tempDirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
function makeTempDir(): string {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-pi-tracer-test-"));
|
|
11
|
+
tempDirs.push(dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readLines(file: string): Record<string, any>[] {
|
|
16
|
+
if (!existsSync(file)) return [];
|
|
17
|
+
return readFileSync(file, "utf-8")
|
|
18
|
+
.split("\n")
|
|
19
|
+
.filter((l) => l.trim().length > 0)
|
|
20
|
+
.map((l) => JSON.parse(l));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
finalizeTracer();
|
|
25
|
+
for (const dir of tempDirs.splice(0)) {
|
|
26
|
+
try {
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("tracer", () => {
|
|
33
|
+
it("is a no-op when not initialized", () => {
|
|
34
|
+
expect(getTracer()).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("creates per-session dir with main.jsonl and writes main events", () => {
|
|
38
|
+
const ppDir = join(makeTempDir(), ".pp");
|
|
39
|
+
initTracer(ppDir, "sess-abc");
|
|
40
|
+
const tracer = getTracer();
|
|
41
|
+
expect(tracer).toBeDefined();
|
|
42
|
+
|
|
43
|
+
tracer!.traceMain("before_agent_start", { prompt: "hello", systemPrompt: "sys" });
|
|
44
|
+
tracer!.traceMain("tool_execution_end", { toolCallId: "t1", result: { ok: true }, isError: false });
|
|
45
|
+
|
|
46
|
+
const dir = join(ppDir, "logs", "traces", "sess-abc");
|
|
47
|
+
expect(existsSync(join(dir, "main.jsonl"))).toBe(true);
|
|
48
|
+
|
|
49
|
+
const lines = readLines(join(dir, "main.jsonl"));
|
|
50
|
+
expect(lines[0].kind).toBe("before_agent_start");
|
|
51
|
+
expect(lines[0].prompt).toBe("hello");
|
|
52
|
+
expect(lines[0].systemPrompt).toBe("sys");
|
|
53
|
+
expect(lines[1].kind).toBe("tool_execution_end");
|
|
54
|
+
expect(lines[1].result).toEqual({ ok: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("writes one file per subagent keyed by id, with prompts and full payloads", () => {
|
|
58
|
+
const ppDir = join(makeTempDir(), ".pp");
|
|
59
|
+
initTracer(ppDir, "sess-1");
|
|
60
|
+
const tracer = getTracer()!;
|
|
61
|
+
|
|
62
|
+
tracer.openSubagent({
|
|
63
|
+
subagentId: "agent-1",
|
|
64
|
+
type: "Explore",
|
|
65
|
+
description: "find stuff",
|
|
66
|
+
parentToolCallId: "call-9",
|
|
67
|
+
parentSubagentId: "root-agent",
|
|
68
|
+
depth: 2,
|
|
69
|
+
systemPrompt: "sub-sys",
|
|
70
|
+
effectivePrompt: "do the thing",
|
|
71
|
+
});
|
|
72
|
+
tracer.traceSubagent("agent-1", "tool_execution_start", { toolCallId: "x", toolName: "grep", args: { pattern: "foo" } });
|
|
73
|
+
|
|
74
|
+
const dir = join(ppDir, "logs", "traces", "sess-1");
|
|
75
|
+
const subLines = readLines(join(dir, "agent-1.jsonl"));
|
|
76
|
+
expect(subLines[0].kind).toBe("subagent_open");
|
|
77
|
+
expect(subLines[0].systemPrompt).toBe("sub-sys");
|
|
78
|
+
expect(subLines[0].effectivePrompt).toBe("do the thing");
|
|
79
|
+
expect(subLines[0].parentToolCallId).toBe("call-9");
|
|
80
|
+
expect(subLines[0].parentSubagentId).toBe("root-agent");
|
|
81
|
+
expect(subLines[0].depth).toBe(2);
|
|
82
|
+
expect(subLines[1].kind).toBe("tool_execution_start");
|
|
83
|
+
expect(subLines[1].args).toEqual({ pattern: "foo" });
|
|
84
|
+
|
|
85
|
+
const mainLines = readLines(join(dir, "main.jsonl"));
|
|
86
|
+
const spawned = mainLines.find((l) => l.kind === "subagent_spawned" && l.subagentId === "agent-1");
|
|
87
|
+
expect(spawned).toBeDefined();
|
|
88
|
+
expect(spawned!.parentToolCallId).toBe("call-9");
|
|
89
|
+
expect(spawned!.parentSubagentId).toBe("root-agent");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps concurrent subagents in separate files", () => {
|
|
93
|
+
const ppDir = join(makeTempDir(), ".pp");
|
|
94
|
+
initTracer(ppDir, "sess-2");
|
|
95
|
+
const tracer = getTracer()!;
|
|
96
|
+
|
|
97
|
+
tracer.openSubagent({ subagentId: "a", depth: 1 });
|
|
98
|
+
tracer.openSubagent({ subagentId: "b", depth: 1 });
|
|
99
|
+
tracer.traceSubagent("a", "turn_end", { turnIndex: 0 });
|
|
100
|
+
tracer.traceSubagent("b", "turn_end", { turnIndex: 0 });
|
|
101
|
+
|
|
102
|
+
const dir = join(ppDir, "logs", "traces", "sess-2");
|
|
103
|
+
expect(readLines(join(dir, "a.jsonl")).some((l) => l.kind === "turn_end")).toBe(true);
|
|
104
|
+
expect(readLines(join(dir, "b.jsonl")).some((l) => l.kind === "turn_end")).toBe(true);
|
|
105
|
+
expect(readLines(join(dir, "a.jsonl")).some((l) => l.subagentId === "b")).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("finalize writes session_finalized and clears the tracer", () => {
|
|
109
|
+
const ppDir = join(makeTempDir(), ".pp");
|
|
110
|
+
initTracer(ppDir, "sess-3");
|
|
111
|
+
const dir = join(ppDir, "logs", "traces", "sess-3");
|
|
112
|
+
finalizeTracer();
|
|
113
|
+
expect(getTracer()).toBeUndefined();
|
|
114
|
+
expect(readLines(join(dir, "main.jsonl")).some((l) => l.kind === "session_finalized")).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("cleans up trace dirs older than 7 days", () => {
|
|
118
|
+
const ppDir = join(makeTempDir(), ".pp");
|
|
119
|
+
initTracer(ppDir, "old-session");
|
|
120
|
+
finalizeTracer();
|
|
121
|
+
const tracesRoot = join(ppDir, "logs", "traces");
|
|
122
|
+
const oldDir = join(tracesRoot, "old-session");
|
|
123
|
+
expect(existsSync(oldDir)).toBe(true);
|
|
124
|
+
|
|
125
|
+
const past = (Date.now() - 8 * 24 * 60 * 60 * 1000) / 1000;
|
|
126
|
+
utimesSync(oldDir, past, past);
|
|
127
|
+
|
|
128
|
+
initTracer(ppDir, "new-session");
|
|
129
|
+
expect(existsSync(oldDir)).toBe(false);
|
|
130
|
+
expect(existsSync(join(tracesRoot, "new-session"))).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|