@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.
Files changed (77) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +68 -28
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +631 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +7 -3
  28. package/extensions/orchestrator/doctor.test.ts +561 -0
  29. package/extensions/orchestrator/doctor.ts +702 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1220 -343
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +511 -0
  35. package/extensions/orchestrator/flant-infra.ts +390 -49
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +3065 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +302 -0
  41. package/extensions/orchestrator/model-registry.ts +298 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +298 -140
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -36
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -31
  52. package/extensions/orchestrator/phases/review-task.ts +3 -7
  53. package/extensions/orchestrator/phases/review.test.ts +54 -0
  54. package/extensions/orchestrator/phases/review.ts +89 -48
  55. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  56. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  57. package/extensions/orchestrator/phases/verdict.ts +82 -0
  58. package/extensions/orchestrator/plannotator.test.ts +85 -0
  59. package/extensions/orchestrator/plannotator.ts +24 -6
  60. package/extensions/orchestrator/pp-menu.test.ts +207 -0
  61. package/extensions/orchestrator/pp-menu.ts +2741 -373
  62. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  63. package/extensions/orchestrator/repo-utils.ts +67 -0
  64. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  65. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  66. package/extensions/orchestrator/state.test.ts +76 -6
  67. package/extensions/orchestrator/state.ts +128 -44
  68. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  69. package/extensions/orchestrator/test-helpers.ts +229 -0
  70. package/extensions/orchestrator/tracer.test.ts +132 -0
  71. package/extensions/orchestrator/tracer.ts +127 -0
  72. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  73. package/extensions/orchestrator/transition-controller.ts +259 -0
  74. package/extensions/orchestrator/usage-tracker.test.ts +563 -0
  75. package/extensions/orchestrator/usage-tracker.ts +96 -12
  76. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  77. package/package.json +2 -1
@@ -1,38 +1,63 @@
1
- import { appendFileSync, readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
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 TaskType = "implement" | "debug" | "brainstorm" | "review";
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 Phase = ImplementPhase | DebugPhase | BrainstormPhase | ReviewPhase;
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
- repoCwd?: string;
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 createTask(cwd: string, type: TaskType, description: string): string {
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 === "implement" ? "brainstorm" : type === "debug" ? "debug" : type === "review" ? "review" : "brainstorm",
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
- console.error(`[pi-pi] Skipping corrupt task at ${dir}`);
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.lockStale,
139
- update: timeouts.lockUpdate,
207
+ stale: timeouts.taskLockStale,
208
+ update: timeouts.taskLockRefresh,
140
209
  retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 },
141
210
  onCompromised: (err: Error) => {
142
- console.error(`[pi-pi] Lock compromised for ${sp}: ${err.message}`);
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
- console.error(`[pi-pi] Failed to check lock for ${task.dir}`);
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 (resolved !== stateRoot && !resolved.startsWith(stateRoot + "/")) {
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
- const state = loadTask(taskDir);
196
- let desc = state.description ?? "";
197
-
198
- if (["implement", "debug", "brainstorm", "review"].includes(desc)) {
199
- const urPath = join(taskDir, "USER_REQUEST.md");
200
- if (existsSync(urPath)) {
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
- if (desc) {
209
- desc = desc.replace(/\s+/g, " ").trim();
210
- if (desc.length > 60) desc = desc.slice(0, 57) + "...";
211
- return desc;
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 match = dir.match(/^\d+_(.+)$/);
218
- return match ? match[1].replace(/-/g, " ") : dir;
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
+ });