@ilya-lesikov/pi-pi 0.4.0 → 0.6.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 (76) 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 +73 -31
  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 +630 -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 +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1191 -360
  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 +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -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 +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -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 +295 -148
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -35
  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 -29
  52. package/extensions/orchestrator/phases/review-task.ts +3 -3
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2631 -392
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -0,0 +1,435 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { createUsageTracker, dumpUsageSummary, loadUsageSummary } from "./usage-tracker.js";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ function makeTempDir(): string {
10
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-usage-tracker-"));
11
+ tempDirs.push(dir);
12
+ return dir;
13
+ }
14
+
15
+ afterEach(() => {
16
+ delete process.env.PI_CODING_AGENT_DIR;
17
+ for (const dir of tempDirs.splice(0)) {
18
+ rmSync(dir, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ describe("usage-tracker", () => {
23
+ it("createUsageTracker starts with zero counts", () => {
24
+ const tracker = createUsageTracker();
25
+
26
+ expect(tracker.getMainInputTokens()).toBe(0);
27
+ expect(tracker.getMainOutputTokens()).toBe(0);
28
+ expect(tracker.getMainCacheReadTokens()).toBe(0);
29
+ expect(tracker.getMainCacheWriteTokens()).toBe(0);
30
+ expect(tracker.getMainCost()).toBe(0);
31
+ expect(tracker.getTotalInputTokens()).toBe(0);
32
+ expect(tracker.getTotalOutputTokens()).toBe(0);
33
+ expect(tracker.getTotalCost()).toBe(0);
34
+ });
35
+
36
+ it("loadFromSummary is idempotent and does not double-count subagents", () => {
37
+ const tracker = createUsageTracker();
38
+ const summary = {
39
+ subagents: [
40
+ { description: "a", agentType: "Explore", modelId: "m", inputTokens: 100, outputTokens: 50, cost: 0.5, toolUses: 1, durationMs: 10 },
41
+ ],
42
+ };
43
+
44
+ tracker.loadFromSummary(summary);
45
+ tracker.loadFromSummary(summary);
46
+
47
+ expect(tracker.getSubagentList()).toHaveLength(1);
48
+ const totals = tracker.getSubagentTotals();
49
+ expect(totals.inputTokens).toBe(100);
50
+ expect(totals.outputTokens).toBe(50);
51
+ expect(totals.cost).toBeCloseTo(0.5);
52
+ });
53
+
54
+ it("recordTurn accumulates input output and cache tokens", () => {
55
+ const tracker = createUsageTracker();
56
+
57
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 20, 3, 4, 0.1, true);
58
+ tracker.recordTurn("openai/gpt-5", "openai", 5, 7, 2, 1, 0.2, true);
59
+
60
+ expect(tracker.getMainInputTokens()).toBe(15);
61
+ expect(tracker.getMainOutputTokens()).toBe(27);
62
+ expect(tracker.getMainCacheReadTokens()).toBe(5);
63
+ expect(tracker.getMainCacheWriteTokens()).toBe(5);
64
+ });
65
+
66
+ it("recordTurn accumulates cost", () => {
67
+ const tracker = createUsageTracker();
68
+
69
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0.25, false);
70
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0.5, false);
71
+
72
+ expect(tracker.getMainCost()).toBe(0.75);
73
+ });
74
+
75
+ it("recordTurn handles non-finite numeric input as zero", () => {
76
+ const tracker = createUsageTracker();
77
+
78
+ tracker.recordTurn("openai/gpt-5", "openai", Number.NaN, Number.POSITIVE_INFINITY, 0, 0, Number.NaN, false);
79
+
80
+ expect(tracker.getMainInputTokens()).toBe(0);
81
+ expect(tracker.getMainOutputTokens()).toBe(0);
82
+ expect(tracker.getMainCost()).toBe(0);
83
+ });
84
+
85
+ it("recordTurn tracks per-model usage", () => {
86
+ const tracker = createUsageTracker();
87
+
88
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 5, 1, 0, 0.2, false);
89
+ tracker.recordTurn("anthropic/claude-opus-4-6", "anthropic", 7, 3, 0, 0, 0.3, false);
90
+
91
+ expect(tracker.getPerModelUsage()).toEqual({
92
+ "openai/gpt-5": {
93
+ inputTokens: 10,
94
+ outputTokens: 5,
95
+ cacheReadTokens: 1,
96
+ cacheWriteTokens: 0,
97
+ cacheSupported: false,
98
+ turns: 1,
99
+ },
100
+ "anthropic/claude-opus-4-6": {
101
+ inputTokens: 7,
102
+ outputTokens: 3,
103
+ cacheReadTokens: 0,
104
+ cacheWriteTokens: 0,
105
+ cacheSupported: false,
106
+ turns: 1,
107
+ },
108
+ });
109
+ });
110
+
111
+ it("recordTurn cacheSupported sets model cache support", () => {
112
+ const tracker = createUsageTracker();
113
+
114
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0, false);
115
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0, true);
116
+
117
+ expect(tracker.getPerModelUsage()["openai/gpt-5"]?.cacheSupported).toBe(true);
118
+ });
119
+
120
+ it("recordTurn uses unknown-model key when model id is empty", () => {
121
+ const tracker = createUsageTracker();
122
+
123
+ tracker.recordTurn("", "openai", 3, 2, 0, 0, 0.1, false);
124
+
125
+ expect(tracker.getPerModelUsage()["unknown-model"]).toMatchObject({
126
+ inputTokens: 3,
127
+ outputTokens: 2,
128
+ turns: 1,
129
+ });
130
+ });
131
+
132
+ it("recordSubagentCompletion accumulates subagent tokens", () => {
133
+ const tracker = createUsageTracker();
134
+
135
+ tracker.recordSubagentCompletion({ input: 11, output: 4 } as any, 0.15);
136
+ tracker.recordSubagentCompletion({ input: 9, output: 6 }, 0.25);
137
+
138
+ expect(tracker.getSubagentTotals()).toEqual({
139
+ inputTokens: 20,
140
+ outputTokens: 10,
141
+ cost: 0.4,
142
+ });
143
+ });
144
+
145
+ it("recordSubagentCompletion defaults metadata fields when missing", () => {
146
+ const tracker = createUsageTracker();
147
+
148
+ tracker.recordSubagentCompletion({ input: 1, output: 2 } as any);
149
+
150
+ expect(tracker.getSubagentList()[0]).toMatchObject({
151
+ description: "unknown",
152
+ agentType: "unknown",
153
+ modelId: "unknown",
154
+ durationMs: 0,
155
+ toolUses: 0,
156
+ });
157
+ });
158
+
159
+ it("recordSubagentCompletion uses tokens.cost over fallback cost", () => {
160
+ const tracker = createUsageTracker();
161
+
162
+ tracker.recordSubagentCompletion({ input: 1, output: 1, cost: 0.6 } as any, 0.2);
163
+
164
+ expect(tracker.getSubagentTotals().cost).toBe(0.6);
165
+ });
166
+
167
+ it("recordSubagentCompletion stores subagent metadata", () => {
168
+ const tracker = createUsageTracker();
169
+
170
+ tracker.recordSubagentCompletion(
171
+ { input: 5, output: 2, cacheRead: 8, cacheWrite: 1 } as any,
172
+ 0.2,
173
+ { description: "Planner", agentType: "planner", modelId: "openai/gpt-5", durationMs: 900, toolUses: 3 },
174
+ );
175
+
176
+ expect(tracker.getSubagentList()).toEqual([
177
+ {
178
+ description: "Planner",
179
+ agentType: "planner",
180
+ modelId: "openai/gpt-5",
181
+ inputTokens: 5,
182
+ outputTokens: 2,
183
+ cacheReadTokens: 8,
184
+ cacheWriteTokens: 1,
185
+ cacheSupported: true,
186
+ cost: 0.2,
187
+ durationMs: 900,
188
+ toolUses: 3,
189
+ },
190
+ ]);
191
+ });
192
+
193
+ it("recordSubagentCompletion uses total when input and output are zero", () => {
194
+ const tracker = createUsageTracker();
195
+
196
+ tracker.recordSubagentCompletion({ total: 77, input: 0, output: 0 });
197
+
198
+ expect(tracker.getSubagentTotals().inputTokens).toBe(77);
199
+ expect(tracker.getSubagentTotals().outputTokens).toBe(0);
200
+ });
201
+
202
+ it("getTotalInputTokens includes main and subagent input", () => {
203
+ const tracker = createUsageTracker();
204
+
205
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 0, 0, 0, 0, false);
206
+ tracker.recordSubagentCompletion({ input: 4, output: 0 });
207
+
208
+ expect(tracker.getTotalInputTokens()).toBe(14);
209
+ });
210
+
211
+ it("getTotalOutputTokens includes main and subagent output", () => {
212
+ const tracker = createUsageTracker();
213
+
214
+ tracker.recordTurn("openai/gpt-5", "openai", 0, 7, 0, 0, 0, false);
215
+ tracker.recordSubagentCompletion({ input: 0, output: 3 } as any);
216
+
217
+ expect(tracker.getTotalOutputTokens()).toBe(10);
218
+ });
219
+
220
+ it("getTotalCost includes main and subagent cost", () => {
221
+ const tracker = createUsageTracker();
222
+
223
+ tracker.recordTurn("openai/gpt-5", "openai", 0, 0, 0, 0, 1.2, false);
224
+ tracker.recordSubagentCompletion({ input: 0, output: 0 }, 0.8);
225
+
226
+ expect(tracker.getTotalCost()).toBe(2);
227
+ });
228
+
229
+ it("getCacheHitRate calculates ratio", () => {
230
+ const tracker = createUsageTracker();
231
+
232
+ tracker.recordTurn("openai/gpt-5", "openai", 30, 0, 10, 0, 0, true);
233
+
234
+ expect(tracker.getCacheHitRate()).toBeCloseTo(10 / 40);
235
+ });
236
+
237
+ it("getCacheHitRate returns zero when denominator is zero", () => {
238
+ const tracker = createUsageTracker();
239
+ expect(tracker.getCacheHitRate()).toBe(0);
240
+ });
241
+
242
+ it("isCacheSupported true when any model or subagent has cache", () => {
243
+ const tracker = createUsageTracker();
244
+ expect(tracker.isCacheSupported()).toBe(false);
245
+
246
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 1, 0, 0, 0, true);
247
+ expect(tracker.isCacheSupported()).toBe(true);
248
+
249
+ tracker.reset();
250
+ tracker.recordSubagentCompletion({ input: 1, output: 1, cacheRead: 1 } as any);
251
+ expect(tracker.isCacheSupported()).toBe(true);
252
+ });
253
+
254
+ it("getPerModelUsage returns a copy", () => {
255
+ const tracker = createUsageTracker();
256
+ tracker.recordTurn("openai/gpt-5", "openai", 2, 3, 0, 0, 0, false);
257
+
258
+ const usage = tracker.getPerModelUsage();
259
+ usage["openai/gpt-5"]!.inputTokens = 999;
260
+
261
+ expect(tracker.getPerModelUsage()["openai/gpt-5"]!.inputTokens).toBe(2);
262
+ });
263
+
264
+ it("toSummary returns complete summary", () => {
265
+ const tracker = createUsageTracker();
266
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 7, 3, 2, 0.1234567, true);
267
+ tracker.recordSubagentCompletion({ input: 4, output: 1 } as any, 0.2, { description: "Explore", agentType: "explore", modelId: "google/gemini-3.1-pro" });
268
+
269
+ const summary = tracker.toSummary() as any;
270
+
271
+ expect(summary.startedAt).toBeTypeOf("string");
272
+ expect(summary.endedAt).toBeTypeOf("string");
273
+ expect(summary.totals).toEqual({
274
+ inputTokens: 10,
275
+ outputTokens: 7,
276
+ cacheReadTokens: 3,
277
+ cacheWriteTokens: 2,
278
+ cost: 0.123457,
279
+ turns: 1,
280
+ });
281
+ expect(summary.subagents).toHaveLength(1);
282
+ expect(summary.models["openai/gpt-5"]).toMatchObject({ inputTokens: 10, outputTokens: 7, turns: 1 });
283
+ });
284
+
285
+ it("loadFromSummary restores state", () => {
286
+ const tracker = createUsageTracker();
287
+ tracker.loadFromSummary({
288
+ startedAt: "2026-01-01T00:00:00.000Z",
289
+ totals: {
290
+ inputTokens: 100,
291
+ outputTokens: 50,
292
+ cacheReadTokens: 20,
293
+ cacheWriteTokens: 5,
294
+ cost: 1.5,
295
+ turns: 3,
296
+ },
297
+ models: {
298
+ "openai/gpt-5": {
299
+ inputTokens: 100,
300
+ outputTokens: 50,
301
+ cacheReadTokens: 20,
302
+ cacheWriteTokens: 5,
303
+ cacheSupported: true,
304
+ turns: 3,
305
+ },
306
+ },
307
+ subagents: [
308
+ {
309
+ description: "Reviewer",
310
+ agentType: "reviewer",
311
+ modelId: "anthropic/claude-opus-4-6",
312
+ inputTokens: 7,
313
+ outputTokens: 2,
314
+ cacheReadTokens: 0,
315
+ cacheWriteTokens: 0,
316
+ cacheSupported: false,
317
+ cost: 0.4,
318
+ durationMs: 200,
319
+ toolUses: 2,
320
+ },
321
+ ],
322
+ });
323
+
324
+ expect(tracker.getMainInputTokens()).toBe(100);
325
+ expect(tracker.getMainOutputTokens()).toBe(50);
326
+ expect(tracker.getMainCacheReadTokens()).toBe(20);
327
+ expect(tracker.getMainCacheWriteTokens()).toBe(5);
328
+ expect(tracker.getMainCost()).toBe(1.5);
329
+ expect(tracker.getSubagentTotals()).toEqual({ inputTokens: 7, outputTokens: 2, cost: 0.4 });
330
+ expect(tracker.getPerModelUsage()["openai/gpt-5"]?.turns).toBe(3);
331
+ });
332
+
333
+ it("loadFromSummary handles malformed summary entries", () => {
334
+ const tracker = createUsageTracker();
335
+
336
+ tracker.loadFromSummary({
337
+ startedAt: 123,
338
+ totals: "bad",
339
+ models: {
340
+ "openai/gpt-5": {
341
+ inputTokens: "x",
342
+ outputTokens: 2,
343
+ cacheSupported: "yes",
344
+ turns: "z",
345
+ },
346
+ },
347
+ subagents: [{ inputTokens: "bad", outputTokens: 4 }],
348
+ } as any);
349
+
350
+ expect(tracker.getMainInputTokens()).toBe(0);
351
+ expect(tracker.getMainOutputTokens()).toBe(0);
352
+ expect(tracker.getPerModelUsage()["openai/gpt-5"]).toMatchObject({
353
+ inputTokens: 0,
354
+ outputTokens: 2,
355
+ cacheSupported: false,
356
+ turns: 0,
357
+ });
358
+ expect(tracker.getSubagentList()[0]).toMatchObject({
359
+ inputTokens: 0,
360
+ outputTokens: 4,
361
+ });
362
+ });
363
+
364
+ it("loadFromSummary and toSummary preserve data round-trip", () => {
365
+ const source = createUsageTracker();
366
+ source.recordTurn("openai/gpt-5", "openai", 13, 8, 2, 1, 0.5, true);
367
+ source.recordSubagentCompletion({ input: 3, output: 1, cacheRead: 1 } as any, 0.1, {
368
+ description: "Librarian",
369
+ agentType: "librarian",
370
+ modelId: "google/gemini-3.1-pro",
371
+ durationMs: 321,
372
+ toolUses: 4,
373
+ });
374
+
375
+ const mid = source.toSummary() as Record<string, unknown>;
376
+ const restored = createUsageTracker();
377
+ restored.loadFromSummary(mid);
378
+ const roundTrip = restored.toSummary() as any;
379
+
380
+ expect(roundTrip.totals).toEqual((mid as any).totals);
381
+ expect(roundTrip.models).toEqual((mid as any).models);
382
+ expect(roundTrip.subagents).toEqual((mid as any).subagents);
383
+ });
384
+
385
+ it("reset clears all state", () => {
386
+ const tracker = createUsageTracker();
387
+ tracker.recordTurn("openai/gpt-5", "openai", 1, 2, 3, 4, 0.2, true);
388
+ tracker.recordSubagentCompletion({ input: 5, output: 6 } as any, 0.1);
389
+
390
+ tracker.reset();
391
+
392
+ expect(tracker.getMainInputTokens()).toBe(0);
393
+ expect(tracker.getMainOutputTokens()).toBe(0);
394
+ expect(tracker.getMainCacheReadTokens()).toBe(0);
395
+ expect(tracker.getMainCacheWriteTokens()).toBe(0);
396
+ expect(tracker.getMainCost()).toBe(0);
397
+ expect(tracker.getSubagentTotals()).toEqual({ inputTokens: 0, outputTokens: 0, cost: 0 });
398
+ expect(tracker.getPerModelUsage()).toEqual({});
399
+ expect(tracker.getSubagentList()).toEqual([]);
400
+ });
401
+
402
+ it("dumpUsageSummary and loadUsageSummary write and read JSON", () => {
403
+ const dir = makeTempDir();
404
+ process.env.PI_CODING_AGENT_DIR = dir;
405
+ const tracker = createUsageTracker();
406
+ tracker.recordTurn("openai/gpt-5", "openai", 11, 12, 1, 0, 0.9, true);
407
+
408
+ dumpUsageSummary(tracker, "session-1");
409
+ const loaded = loadUsageSummary("session-1") as any;
410
+
411
+ expect(loaded.sessionId).toBe("session-1");
412
+ expect(loaded.totals.inputTokens).toBe(11);
413
+ expect(loaded.totals.outputTokens).toBe(12);
414
+ expect(loaded.models["openai/gpt-5"].turns).toBe(1);
415
+
416
+ const filePath = join(dir, "extensions", "pp", "usage", "session-1.json");
417
+ const raw = readFileSync(filePath, "utf-8");
418
+ expect(raw.endsWith("\n")).toBe(true);
419
+ });
420
+
421
+ it("loadUsageSummary returns null for missing and invalid files", () => {
422
+ const dir = makeTempDir();
423
+ process.env.PI_CODING_AGENT_DIR = dir;
424
+
425
+ expect(loadUsageSummary("missing")).toBeNull();
426
+
427
+ const usageDir = join(dir, "extensions", "pp", "usage");
428
+ const invalidPath = join(usageDir, "broken.json");
429
+ rmSync(usageDir, { recursive: true, force: true });
430
+ mkdirSync(usageDir, { recursive: true });
431
+ writeFileSync(invalidPath, "{broken", "utf-8");
432
+
433
+ expect(loadUsageSummary("broken")).toBeNull();
434
+ });
435
+ });
@@ -7,11 +7,12 @@ export interface ModelUsage {
7
7
  outputTokens: number;
8
8
  cacheReadTokens: number;
9
9
  cacheWriteTokens: number;
10
+ cacheSupported: boolean;
10
11
  turns: number;
11
12
  }
12
13
 
13
14
  export interface UsageTracker {
14
- recordTurn(modelId: string, provider: string, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number): void;
15
+ recordTurn(modelId: string, provider: string, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number, cacheSupported?: boolean): void;
15
16
  recordSubagentCompletion(tokens: { input?: number; output?: number; total?: number }, cost?: number, meta?: { description?: string; agentType?: string; modelId?: string; durationMs?: number; toolUses?: number }): void;
16
17
  loadFromSummary(summary: Record<string, unknown>): void;
17
18
  getTotalInputTokens(): number;
@@ -25,6 +26,7 @@ export interface UsageTracker {
25
26
  getMainCacheWriteTokens(): number;
26
27
  getMainCost(): number;
27
28
  getCacheHitRate(): number;
29
+ isCacheSupported(): boolean;
28
30
  getPerModelUsage(): Record<string, ModelUsage>;
29
31
  getSubagentTotals(): { inputTokens: number; outputTokens: number; cost: number };
30
32
  getSubagentList(): SubagentUsage[];
@@ -40,6 +42,7 @@ export interface SubagentUsage {
40
42
  outputTokens: number;
41
43
  cacheReadTokens: number;
42
44
  cacheWriteTokens: number;
45
+ cacheSupported: boolean;
43
46
  cost: number;
44
47
  durationMs: number;
45
48
  toolUses: number;
@@ -96,7 +99,7 @@ export function createUsageTracker(): UsageTracker {
96
99
  const state = createInitialState();
97
100
 
98
101
  return {
99
- recordTurn(modelId: string, _provider: string, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number): void {
102
+ recordTurn(modelId: string, _provider: string, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number, cacheSupported?: boolean): void {
100
103
  const safeInput = toFiniteNumber(input);
101
104
  const safeOutput = toFiniteNumber(output);
102
105
  const safeCacheRead = toFiniteNumber(cacheRead);
@@ -116,6 +119,7 @@ export function createUsageTracker(): UsageTracker {
116
119
  outputTokens: 0,
117
120
  cacheReadTokens: 0,
118
121
  cacheWriteTokens: 0,
122
+ cacheSupported: false,
119
123
  turns: 0,
120
124
  };
121
125
 
@@ -123,6 +127,7 @@ export function createUsageTracker(): UsageTracker {
123
127
  usage.outputTokens += safeOutput;
124
128
  usage.cacheReadTokens += safeCacheRead;
125
129
  usage.cacheWriteTokens += safeCacheWrite;
130
+ if (cacheSupported) usage.cacheSupported = true;
126
131
  usage.turns += 1;
127
132
  state.models.set(key, usage);
128
133
  },
@@ -145,6 +150,8 @@ export function createUsageTracker(): UsageTracker {
145
150
  state.subagentOutputTokens += safeOutput;
146
151
  state.subagentCost += safeCost;
147
152
 
153
+ const cacheSupported = typeof tokens.cacheRead === "number" || typeof tokens.cacheWrite === "number";
154
+
148
155
  state.subagents.push({
149
156
  description: meta?.description ?? "unknown",
150
157
  agentType: meta?.agentType ?? "unknown",
@@ -153,6 +160,7 @@ export function createUsageTracker(): UsageTracker {
153
160
  outputTokens: safeOutput,
154
161
  cacheReadTokens: safeCacheRead,
155
162
  cacheWriteTokens: safeCacheWrite,
163
+ cacheSupported,
156
164
  cost: safeCost,
157
165
  durationMs: toFiniteNumber(meta?.durationMs),
158
166
  toolUses: toFiniteNumber(meta?.toolUses),
@@ -172,6 +180,10 @@ export function createUsageTracker(): UsageTracker {
172
180
  state.totalTurns = toFiniteNumber(totals.turns);
173
181
  }
174
182
  if (Array.isArray(summary.subagents)) {
183
+ state.subagents = [];
184
+ state.subagentInputTokens = 0;
185
+ state.subagentOutputTokens = 0;
186
+ state.subagentCost = 0;
175
187
  for (const sa of summary.subagents as Record<string, unknown>[]) {
176
188
  const entry: SubagentUsage = {
177
189
  description: typeof sa.description === "string" ? sa.description : "unknown",
@@ -181,6 +193,7 @@ export function createUsageTracker(): UsageTracker {
181
193
  outputTokens: toFiniteNumber(sa.outputTokens),
182
194
  cacheReadTokens: toFiniteNumber(sa.cacheReadTokens),
183
195
  cacheWriteTokens: toFiniteNumber(sa.cacheWriteTokens),
196
+ cacheSupported: sa.cacheSupported === true,
184
197
  cost: toFiniteNumber(sa.cost),
185
198
  durationMs: toFiniteNumber(sa.durationMs),
186
199
  toolUses: toFiniteNumber(sa.toolUses),
@@ -198,6 +211,7 @@ export function createUsageTracker(): UsageTracker {
198
211
  outputTokens: toFiniteNumber(usage.outputTokens),
199
212
  cacheReadTokens: toFiniteNumber(usage.cacheReadTokens),
200
213
  cacheWriteTokens: toFiniteNumber(usage.cacheWriteTokens),
214
+ cacheSupported: (usage as any).cacheSupported === true,
201
215
  turns: toFiniteNumber(usage.turns),
202
216
  });
203
217
  }
@@ -255,6 +269,13 @@ export function createUsageTracker(): UsageTracker {
255
269
  return totalCacheRead / denominator;
256
270
  },
257
271
 
272
+ isCacheSupported(): boolean {
273
+ for (const usage of state.models.values()) {
274
+ if (usage.cacheSupported) return true;
275
+ }
276
+ return state.subagents.some((sa) => sa.cacheSupported);
277
+ },
278
+
258
279
  getPerModelUsage(): Record<string, ModelUsage> {
259
280
  const out: Record<string, ModelUsage> = {};
260
281
  for (const [model, usage] of state.models.entries()) {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { validatePlan, validateUserRequest } from "./validate-artifacts.js";
2
+ import { validateArtifact, validatePlan, validateResearch, validateUserRequest } from "./validate-artifacts.js";
3
3
 
4
4
  describe("validateUserRequest", () => {
5
5
  it("rejects placeholder distillation and constraints content", () => {
@@ -86,3 +86,85 @@ Ship minimal fix.
86
86
  expect(result.errors).toContain("Section ## Scope is empty. Expected 2-4 lines summarizing scope and constraints.");
87
87
  });
88
88
  });
89
+
90
+ describe("validateResearch", () => {
91
+ it("accepts valid research content", () => {
92
+ const content = `## Affected Code
93
+ src/main.ts:run — entry point
94
+
95
+ ## Architecture Context
96
+ - Main flow calls run and dispatches handlers
97
+
98
+ ## Constraints & Edge Cases
99
+ - MUST: Keep behavior backward compatible
100
+ - RISK: Regression in startup path
101
+
102
+ ## Open Questions
103
+ Need confirmation about deprecated flag
104
+ `;
105
+
106
+ const result = validateResearch(content);
107
+
108
+ expect(result.ok).toBe(true);
109
+ });
110
+
111
+ it("fails when required sections are missing", () => {
112
+ const content = `## Affected Code
113
+ src/main.ts:run — entry point
114
+
115
+ ## Constraints & Edge Cases
116
+ - MUST: Keep behavior backward compatible
117
+ `;
118
+
119
+ const result = validateResearch(content);
120
+
121
+ expect(result.ok).toBe(false);
122
+ if (result.ok) return;
123
+ expect(result.errors.some((error) => error.includes("Missing required section: ## Architecture Context"))).toBe(true);
124
+ });
125
+
126
+ it("fails when affected code section is empty", () => {
127
+ const content = `## Affected Code
128
+ ...
129
+
130
+ ## Architecture Context
131
+ - Main flow calls run and dispatches handlers
132
+
133
+ ## Constraints & Edge Cases
134
+ - MUST: Keep behavior backward compatible
135
+ `;
136
+
137
+ const result = validateResearch(content);
138
+
139
+ expect(result.ok).toBe(false);
140
+ if (result.ok) return;
141
+ expect(result.errors).toContain("Section ## Affected Code is empty. Expected non-empty content.");
142
+ });
143
+ });
144
+
145
+ describe("validateArtifact", () => {
146
+ it("accepts valid artifact with top-level title", () => {
147
+ const content = `# Risk Analysis
148
+
149
+ - First risk
150
+ - Second risk
151
+ `;
152
+
153
+ const result = validateArtifact(content);
154
+
155
+ expect(result.ok).toBe(true);
156
+ });
157
+
158
+ it("fails when top-level title heading is missing", () => {
159
+ const content = `## Risk Analysis
160
+
161
+ - First risk
162
+ `;
163
+
164
+ const result = validateArtifact(content);
165
+
166
+ expect(result.ok).toBe(false);
167
+ if (result.ok) return;
168
+ expect(result.errors.some((error) => error.includes("Expected a top-level heading"))).toBe(true);
169
+ });
170
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilya-lesikov/pi-pi",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pi-Pi Coding Agent: based on Pi Coding Agent, but twice as good",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -52,6 +52,7 @@
52
52
  "@pierre/diffs": "^1.2.0",
53
53
  "effect": "^3.0.0",
54
54
  "minimatch": "^10.0.0",
55
+ "pino": "^10.3.1",
55
56
  "proper-lockfile": "^4.1.2",
56
57
  "turndown": "^7.2.0",
57
58
  "typebox": "^1.0.0"