@ilya-lesikov/pi-pi 0.9.0 → 0.11.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 (108) hide show
  1. package/3p/pi-ask-user/index.ts +71 -124
  2. package/3p/pi-ask-user/single-select-layout.ts +3 -14
  3. package/3p/pi-subagents/package.json +12 -7
  4. package/3p/pi-subagents/src/agent-manager.ts +243 -79
  5. package/3p/pi-subagents/src/agent-runner.ts +501 -365
  6. package/3p/pi-subagents/src/agent-types.ts +46 -57
  7. package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
  8. package/3p/pi-subagents/src/custom-agents.ts +30 -6
  9. package/3p/pi-subagents/src/default-agents.ts +25 -43
  10. package/3p/pi-subagents/src/enabled-models.ts +180 -0
  11. package/3p/pi-subagents/src/index.ts +599 -839
  12. package/3p/pi-subagents/src/model-resolver.ts +26 -7
  13. package/3p/pi-subagents/src/output-file.ts +21 -2
  14. package/3p/pi-subagents/src/prompts.ts +17 -3
  15. package/3p/pi-subagents/src/schedule-store.ts +153 -0
  16. package/3p/pi-subagents/src/schedule.ts +365 -0
  17. package/3p/pi-subagents/src/settings.ts +261 -0
  18. package/3p/pi-subagents/src/skill-loader.ts +77 -54
  19. package/3p/pi-subagents/src/status-note.ts +25 -0
  20. package/3p/pi-subagents/src/types.ts +97 -6
  21. package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
  22. package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
  23. package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
  24. package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
  25. package/3p/pi-subagents/src/usage.ts +60 -0
  26. package/3p/pi-subagents/src/worktree.ts +49 -20
  27. package/extensions/orchestrator/agents/advisor.ts +13 -8
  28. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
  29. package/extensions/orchestrator/agents/code-reviewer.ts +8 -3
  30. package/extensions/orchestrator/agents/constraints.test.ts +26 -0
  31. package/extensions/orchestrator/agents/constraints.ts +12 -2
  32. package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
  33. package/extensions/orchestrator/agents/explore.ts +12 -6
  34. package/extensions/orchestrator/agents/librarian.ts +13 -5
  35. package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
  36. package/extensions/orchestrator/agents/planner.ts +8 -3
  37. package/extensions/orchestrator/agents/pools.test.ts +56 -0
  38. package/extensions/orchestrator/agents/prompts.test.ts +52 -10
  39. package/extensions/orchestrator/agents/registry.test.ts +245 -0
  40. package/extensions/orchestrator/agents/registry.ts +145 -10
  41. package/extensions/orchestrator/agents/reviewer.ts +14 -8
  42. package/extensions/orchestrator/agents/task.ts +12 -6
  43. package/extensions/orchestrator/agents/tool-routing.ts +213 -85
  44. package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
  45. package/extensions/orchestrator/ast-search.test.ts +124 -0
  46. package/extensions/orchestrator/billing-spoof.test.ts +67 -0
  47. package/extensions/orchestrator/billing-spoof.ts +95 -0
  48. package/extensions/orchestrator/cbm.more.test.ts +358 -0
  49. package/extensions/orchestrator/command-handlers.ts +6 -0
  50. package/extensions/orchestrator/commands.test.ts +15 -2
  51. package/extensions/orchestrator/commands.ts +1 -1
  52. package/extensions/orchestrator/config.test.ts +89 -1
  53. package/extensions/orchestrator/config.ts +102 -19
  54. package/extensions/orchestrator/context.test.ts +46 -0
  55. package/extensions/orchestrator/context.ts +18 -5
  56. package/extensions/orchestrator/custom-footer.test.ts +24 -10
  57. package/extensions/orchestrator/custom-footer.ts +4 -2
  58. package/extensions/orchestrator/doctor.more.test.ts +315 -0
  59. package/extensions/orchestrator/doctor.ts +6 -2
  60. package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
  61. package/extensions/orchestrator/event-handlers.test.ts +96 -9
  62. package/extensions/orchestrator/event-handlers.ts +344 -151
  63. package/extensions/orchestrator/exa.more.test.ts +118 -0
  64. package/extensions/orchestrator/flant-infra.more.test.ts +381 -0
  65. package/extensions/orchestrator/flant-infra.test.ts +127 -0
  66. package/extensions/orchestrator/flant-infra.ts +126 -41
  67. package/extensions/orchestrator/index.test.ts +76 -0
  68. package/extensions/orchestrator/index.ts +2 -0
  69. package/extensions/orchestrator/integration.test.ts +183 -65
  70. package/extensions/orchestrator/model-registry.test.ts +2 -1
  71. package/extensions/orchestrator/model-registry.ts +12 -2
  72. package/extensions/orchestrator/orchestrator.test.ts +67 -0
  73. package/extensions/orchestrator/orchestrator.ts +119 -27
  74. package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
  75. package/extensions/orchestrator/phases/brainstorm.ts +43 -6
  76. package/extensions/orchestrator/phases/implementation.ts +2 -0
  77. package/extensions/orchestrator/phases/machine.test.ts +17 -1
  78. package/extensions/orchestrator/phases/machine.ts +4 -1
  79. package/extensions/orchestrator/phases/planning.test.ts +47 -1
  80. package/extensions/orchestrator/phases/planning.ts +18 -3
  81. package/extensions/orchestrator/phases/review-task.ts +5 -0
  82. package/extensions/orchestrator/phases/review.test.ts +10 -0
  83. package/extensions/orchestrator/phases/review.ts +22 -7
  84. package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
  85. package/extensions/orchestrator/phases/verdict.ts +6 -5
  86. package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
  87. package/extensions/orchestrator/plannotator.test.ts +38 -1
  88. package/extensions/orchestrator/plannotator.ts +50 -3
  89. package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
  90. package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
  91. package/extensions/orchestrator/pp-menu.test.ts +114 -7
  92. package/extensions/orchestrator/pp-menu.ts +580 -91
  93. package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
  94. package/extensions/orchestrator/pp-state-tools.ts +65 -0
  95. package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
  96. package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
  97. package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
  98. package/extensions/orchestrator/review-files.test.ts +26 -0
  99. package/extensions/orchestrator/review-files.ts +3 -0
  100. package/extensions/orchestrator/state.test.ts +73 -1
  101. package/extensions/orchestrator/state.ts +95 -23
  102. package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
  103. package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
  104. package/extensions/orchestrator/validate-artifacts.ts +1 -1
  105. package/package.json +9 -2
  106. package/scripts/lib/smoke-resolve.mjs +99 -0
  107. package/scripts/test-3p.sh +52 -0
  108. package/scripts/test-package.sh +62 -0
@@ -56,6 +56,20 @@ export interface TaskState {
56
56
  mode?: TaskMode;
57
57
  effectiveMode?: TaskMode;
58
58
  autonomousConfig?: AutonomousConfig;
59
+ // Manual auto-review-until-approved (#5/#7): drives the same review-cycle loop
60
+ // as autonomous mode over ONE phase, independently of effectiveMode (so it
61
+ // works in force-guided brainstorm/debug/review). advanceOnComplete=false
62
+ // (item 5) stops in-phase on approve/max-passes; =true (item 7) finalizes and
63
+ // transitions to the next phase. deferredAdvance carries the next-phase inputs
64
+ // the plain-continue path would have collected, captured up front because the
65
+ // headless pp_phase_complete branch cannot prompt.
66
+ manualAutoReview?: {
67
+ phase: string;
68
+ preset: string;
69
+ maxPasses: number;
70
+ advanceOnComplete: boolean;
71
+ deferredAdvance?: { mode?: TaskMode; autonomousConfig?: AutonomousConfig; plannerPreset?: string };
72
+ };
59
73
  plannerFailureAutoRetried?: boolean;
60
74
  reviewerFailureAutoRetried?: boolean;
61
75
  // Set once afterImplement has run for the current implement phase (either at
@@ -66,6 +80,10 @@ export interface TaskState {
66
80
  // set of repos to review; index is the next repo. Persisted so the loop resumes
67
81
  // on the next /pp after the agent fixes one repo's feedback. Undefined = no loop.
68
82
  plannotatorCursor?: { repoPaths: string[]; index: number };
83
+ // The phase the task was in when it was marked done. Recorded so the Resume
84
+ // "reopen a done task" flow (#2) can restore the actual last working phase
85
+ // (done itself carries no phase history). Absent on legacy done tasks.
86
+ completedFrom?: Phase;
69
87
  }
70
88
 
71
89
  export interface TaskInfo {
@@ -174,11 +192,18 @@ export function saveTask(taskDir: string, state: TaskState): void {
174
192
  writeFileSync(taskStatePath(taskDir), JSON.stringify(state, null, 2) + "\n", "utf-8");
175
193
  }
176
194
 
177
- export function listTasks(cwd: string, type?: TaskType): TaskInfo[] {
195
+ export interface ListTasksOptions {
196
+ type?: TaskType;
197
+ includeDone?: boolean;
198
+ }
199
+
200
+ export function listTasks(cwd: string, typeOrOptions?: TaskType | ListTasksOptions): TaskInfo[] {
201
+ const options: ListTasksOptions =
202
+ typeof typeOrOptions === "string" ? { type: typeOrOptions } : typeOrOptions ?? {};
178
203
  const base = stateDir(cwd);
179
204
  if (!existsSync(base)) return [];
180
205
 
181
- const types: TaskType[] = type ? [type] : ["implement", "debug", "brainstorm", "review", "quick"];
206
+ const types: TaskType[] = options.type ? [options.type] : ["implement", "debug", "brainstorm", "review", "quick"];
182
207
  const results: TaskInfo[] = [];
183
208
 
184
209
  for (const t of types) {
@@ -193,7 +218,7 @@ export function listTasks(cwd: string, type?: TaskType): TaskInfo[] {
193
218
 
194
219
  try {
195
220
  const state = loadTask(dir);
196
- if (state.phase !== "done") {
221
+ if (state.phase !== "done" || options.includeDone) {
197
222
  results.push({ dir, state, type: t });
198
223
  }
199
224
  } catch {
@@ -229,9 +254,9 @@ export async function lockTask(taskDir: string, timeouts: TimeoutConfig): Promis
229
254
  return release;
230
255
  }
231
256
 
232
- export function getActiveTask(cwd: string, lockStale?: number): TaskInfo | null {
257
+ export function getUnlockedTasks(cwd: string, lockStale?: number): TaskInfo[] {
233
258
  const tasks = listTasks(cwd);
234
- if (tasks.length === 0) return null;
259
+ if (tasks.length === 0) return [];
235
260
 
236
261
  const stale = lockStale ?? 600000;
237
262
  const unlocked: TaskInfo[] = [];
@@ -245,9 +270,24 @@ export function getActiveTask(cwd: string, lockStale?: number): TaskInfo | null
245
270
  getLogger().warn({ s: "state", dir: task.dir }, "failed to check lock");
246
271
  }
247
272
  }
273
+ return unlocked;
274
+ }
248
275
 
249
- if (unlocked.length !== 1) return null;
250
- return unlocked[0];
276
+ export type ActiveTaskStatus =
277
+ | { kind: "none" }
278
+ | { kind: "single"; task: TaskInfo }
279
+ | { kind: "ambiguous"; tasks: TaskInfo[] };
280
+
281
+ export function getActiveTaskStatus(cwd: string, lockStale?: number): ActiveTaskStatus {
282
+ const unlocked = getUnlockedTasks(cwd, lockStale);
283
+ if (unlocked.length === 0) return { kind: "none" };
284
+ if (unlocked.length === 1) return { kind: "single", task: unlocked[0]! };
285
+ return { kind: "ambiguous", tasks: unlocked };
286
+ }
287
+
288
+ export function getActiveTask(cwd: string, lockStale?: number): TaskInfo | null {
289
+ const status = getActiveTaskStatus(cwd, lockStale);
290
+ return status.kind === "single" ? status.task : null;
251
291
  }
252
292
 
253
293
  export function validateFromPath(cwd: string, fromPath: string): { ok: true; dir: string } | { ok: false; reason: string } {
@@ -284,28 +324,60 @@ export function taskName(taskDir: string): string {
284
324
  }
285
325
  }
286
326
 
287
- // Same as taskName but reuses an already-loaded TaskState (avoids re-reading
288
- // state.json per resume-menu entry). The only per-entry disk read is the
289
- // USER_REQUEST.md fallback, which is unavoidable for generic descriptions.
290
- export function taskNameFromState(taskDir: string, state: TaskState): string {
327
+ // Non-heading, non-empty content of a markdown file, collapsed onto one line
328
+ // (multiple content lines joined with a space), or null. Spanning multiple
329
+ // lines lets a multi-line request survive the later truncate-with-ellipsis
330
+ // instead of being cut off at the first content line. Bounded so a large file
331
+ // (e.g. RESEARCH.md) never builds an unbounded string.
332
+ function firstMarkdownContent(path: string): string | null {
333
+ if (!existsSync(path)) return null;
334
+ const content = readFileSync(path, "utf-8");
335
+ const lines = content.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
336
+ if (lines.length === 0) return null;
337
+ return lines.join(" ").slice(0, 700);
338
+ }
339
+
340
+ // The full, untrimmed task name (used for the Resume right-pane description).
341
+ // Resolves a real intent for generic-description tasks via a fallback chain:
342
+ // USER_REQUEST.md → RESEARCH.md → first artifact → dir slug.
343
+ export function taskFullName(taskDir: string, state: TaskState): string {
291
344
  let desc = state.description ?? "";
292
345
 
293
346
  if (["implement", "debug", "brainstorm", "review", "quick"].includes(desc)) {
294
- const urPath = join(taskDir, "USER_REQUEST.md");
295
- if (existsSync(urPath)) {
296
- const content = readFileSync(urPath, "utf-8");
297
- const lines = content.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
298
- const firstContent = lines.find((l) => !l.startsWith("#"));
299
- if (firstContent) desc = firstContent;
300
- }
347
+ const fallback =
348
+ firstMarkdownContent(join(taskDir, "USER_REQUEST.md")) ??
349
+ firstMarkdownContent(join(taskDir, "RESEARCH.md")) ??
350
+ firstArtifactTitle(taskDir);
351
+ if (fallback) desc = fallback;
301
352
  }
302
353
 
303
- if (desc) {
304
- desc = desc.replace(/\s+/g, " ").trim();
305
- if (desc.length > 60) desc = desc.slice(0, 57) + "...";
306
- return desc;
354
+ desc = desc.replace(/\s+/g, " ").trim();
355
+ return desc || dirSlugName(taskDir);
356
+ }
357
+
358
+ function firstArtifactTitle(taskDir: string): string | null {
359
+ const artifactsDir = join(taskDir, "artifacts");
360
+ if (!existsSync(artifactsDir)) return null;
361
+ const files = readdirSync(artifactsDir).filter((f) => f.endsWith(".md")).sort();
362
+ for (const file of files) {
363
+ const content = readFileSync(join(artifactsDir, file), "utf-8");
364
+ const lines = content.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
365
+ const heading = lines.find((l) => l.startsWith("# "));
366
+ if (heading) return heading.replace(/^#\s+/, "");
367
+ const firstContent = lines.find((l) => !l.startsWith("#"));
368
+ if (firstContent) return firstContent;
307
369
  }
308
- return dirSlugName(taskDir);
370
+ return null;
371
+ }
372
+
373
+ // Same as taskName but reuses an already-loaded TaskState (avoids re-reading
374
+ // state.json per resume-menu entry). Returns a TITLE trimmed to fit one line.
375
+ // The cap is conservative (fits within ~80 cols after the `→ N. ` selection
376
+ // prefix and a ` — <age>` suffix are added) so a Resume item never wraps.
377
+ export function taskNameFromState(taskDir: string, state: TaskState): string {
378
+ const desc = taskFullName(taskDir, state);
379
+ if (desc.length > 60) return desc.slice(0, 57) + "...";
380
+ return desc;
309
381
  }
310
382
 
311
383
  function dirSlugName(taskDir: string): string {
@@ -0,0 +1,56 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { suppressPierreThemeSpam } from "./suppress-pierre-theme-spam.js";
3
+
4
+ const INSTALLED_KEY = Symbol.for("pi-pi:pierre-theme-spam-suppressed");
5
+ const DUP = "SharedHighlight.registerCustomTheme: theme name already registered";
6
+
7
+ describe("suppressPierreThemeSpam", () => {
8
+ let original: typeof console.error;
9
+
10
+ beforeEach(() => {
11
+ original = console.error;
12
+ (globalThis as any)[INSTALLED_KEY] = undefined;
13
+ });
14
+
15
+ afterEach(() => {
16
+ console.error = original;
17
+ (globalThis as any)[INSTALLED_KEY] = undefined;
18
+ });
19
+
20
+ it("drops the benign duplicate-theme error for each known pierre theme", () => {
21
+ const spy = vi.fn();
22
+ console.error = spy;
23
+ suppressPierreThemeSpam();
24
+
25
+ for (const name of ["pierre-dark", "pierre-dark-soft", "pierre-light", "pierre-light-soft"]) {
26
+ console.error(DUP, name);
27
+ }
28
+ expect(spy).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it("passes through unrelated console.error calls untouched", () => {
32
+ const spy = vi.fn();
33
+ console.error = spy;
34
+ suppressPierreThemeSpam();
35
+
36
+ console.error("something else entirely");
37
+ console.error(DUP, "some-other-theme"); // same prefix, unknown theme → not ours
38
+ console.error("real error", new Error("boom"));
39
+
40
+ expect(spy).toHaveBeenCalledTimes(3);
41
+ expect(spy).toHaveBeenNthCalledWith(1, "something else entirely");
42
+ expect(spy).toHaveBeenNthCalledWith(2, DUP, "some-other-theme");
43
+ });
44
+
45
+ it("installs the filter only once (idempotent, no wrapper stacking)", () => {
46
+ const spy = vi.fn();
47
+ console.error = spy;
48
+ suppressPierreThemeSpam();
49
+ const afterFirst = console.error;
50
+ suppressPierreThemeSpam();
51
+ expect(console.error).toBe(afterFirst);
52
+
53
+ console.error("passthrough");
54
+ expect(spy).toHaveBeenCalledTimes(1);
55
+ });
56
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Silences a benign, TUI-corrupting `console.error` from `@pierre/diffs`.
3
+ *
4
+ * `@pierre/diffs/dist/highlighter/shared_highlighter.js` registers four custom
5
+ * themes (`pierre-dark`, `pierre-dark-soft`, `pierre-light`, `pierre-light-soft`)
6
+ * as an import-time side effect, against a module-singleton Map. When that module
7
+ * graph is evaluated more than once — which it is here, because pi loads
8
+ * extensions through jiti with `moduleCache: false` and the /pp menu is pulled in
9
+ * via a dynamic `import()` — each re-registration trips a
10
+ * `console.error("SharedHighlight.registerCustomTheme: theme name already registered", name)`.
11
+ *
12
+ * pi takes over stdout during the interactive TUI (its output-guard reroutes
13
+ * stdout→stderr) but does not guard stderr, so that stray error visibly corrupts
14
+ * the rendered UI (seen when opening/closing /pp).
15
+ *
16
+ * We can't dedupe the registration (jiti's cache is off) and there's no
17
+ * patch-package infra in this repo, so we drop exactly this one third-party
18
+ * message and delegate everything else untouched. Installed once, process-wide.
19
+ */
20
+
21
+ const DUP_THEME_PREFIX = "SharedHighlight.registerCustomTheme: theme name already registered";
22
+ const PIERRE_THEME_NAMES = new Set(["pierre-dark", "pierre-dark-soft", "pierre-light", "pierre-light-soft"]);
23
+
24
+ // Marked on globalThis rather than a module-local flag: pi loads extensions via
25
+ // jiti with `moduleCache: false`, so this module can be re-evaluated (resetting
26
+ // module scope) — without a process-wide marker we'd stack console.error wrappers.
27
+ const INSTALLED_KEY = Symbol.for("pi-pi:pierre-theme-spam-suppressed");
28
+
29
+ export function suppressPierreThemeSpam(): void {
30
+ if ((globalThis as any)[INSTALLED_KEY]) return;
31
+ (globalThis as any)[INSTALLED_KEY] = true;
32
+
33
+ const original = console.error.bind(console);
34
+ console.error = (...args: unknown[]) => {
35
+ if (
36
+ args.length >= 2 &&
37
+ args[0] === DUP_THEME_PREFIX &&
38
+ typeof args[1] === "string" &&
39
+ PIERRE_THEME_NAMES.has(args[1])
40
+ ) {
41
+ return;
42
+ }
43
+ original(...args);
44
+ };
45
+ }
@@ -5,7 +5,7 @@ const USER_REQUEST_ALLOWED_SECTIONS = ["Problem", "Constraints"] as const;
5
5
  const RESEARCH_REQUIRED_SECTIONS = ["Affected Code", "Architecture Context", "Constraints & Edge Cases"] as const;
6
6
  const RESEARCH_ALLOWED_SECTIONS = ["Affected Code", "Architecture Context", "Constraints & Edge Cases", "Open Questions"] as const;
7
7
  const PLAN_REQUIRED_SECTIONS = ["Scope", "Checklist"] as const;
8
- const PLAN_ALLOWED_SECTIONS = ["Scope", "Checklist", "Pattern constraints", "Blockers"] as const;
8
+ export const PLAN_ALLOWED_SECTIONS = ["Scope", "Checklist", "Pattern constraints", "Blockers"] as const;
9
9
  const PLACEHOLDER_PATTERNS = /^(?:[-*.…—]|tbd|todo|n\/a|na|none|\.{2,})$/i;
10
10
 
11
11
  function splitLines(content: string): string[] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilya-lesikov/pi-pi",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Pi-Pi Coding Agent: based on Pi Coding Agent, but twice as good",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -35,7 +35,11 @@
35
35
  "scripts": {
36
36
  "postinstall": "bash scripts/postinstall.sh",
37
37
  "test": "vitest run --exclude '3p/**'",
38
- "test:watch": "vitest --exclude '3p/**'"
38
+ "test:watch": "vitest --exclude '3p/**'",
39
+ "test:3p": "bash scripts/test-3p.sh",
40
+ "test:package": "bash scripts/test-package.sh",
41
+ "test:all": "vitest run --exclude '3p/**' && bash scripts/test-3p.sh && bash scripts/test-package.sh",
42
+ "coverage": "vitest run --exclude '3p/**' --coverage"
39
43
  },
40
44
  "pi": {
41
45
  "extensions": [
@@ -50,8 +54,10 @@
50
54
  "dependencies": {
51
55
  "@joplin/turndown-plugin-gfm": "^1.0.64",
52
56
  "@pierre/diffs": "^1.2.0",
57
+ "croner": "^10.0.1",
53
58
  "effect": "^3.0.0",
54
59
  "minimatch": "^10.0.0",
60
+ "nanoid": "^5.0.0",
55
61
  "pino": "^10.3.1",
56
62
  "proper-lockfile": "^4.1.2",
57
63
  "turndown": "^7.2.0",
@@ -68,6 +74,7 @@
68
74
  "@earendil-works/pi-coding-agent": "^0.75.5",
69
75
  "@earendil-works/pi-tui": "^0.75.5",
70
76
  "@sinclair/typebox": "^0.34.0",
77
+ "@vitest/coverage-v8": "^4.1.10",
71
78
  "typescript": "^5.8.0",
72
79
  "vitest": "^4.1.4"
73
80
  }
@@ -0,0 +1,99 @@
1
+ // Runs from INSIDE the installed package directory (import.meta.url is under the package), so
2
+ // dynamic import() of a bare specifier resolves through the installed artifact's own
3
+ // node_modules chain — the same resolution pi performs at extension load. Exits non-zero if a
4
+ // registered extension path is missing or a required runtime specifier fails to load.
5
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const PKG_DIR = dirname(fileURLToPath(import.meta.url));
10
+
11
+ function fail(msg) {
12
+ console.error(` ✗ ${msg}`);
13
+ process.exitCode = 1;
14
+ }
15
+
16
+ const pkg = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
17
+
18
+ const extEntries = pkg?.pi?.extensions ?? [];
19
+ if (extEntries.length === 0) fail("package.json has no pi.extensions entries");
20
+ for (const entry of extEntries) {
21
+ if (existsSync(resolve(PKG_DIR, entry))) {
22
+ console.log(` ✓ extension present: ${entry}`);
23
+ } else {
24
+ fail(`registered extension path missing from artifact: ${entry}`);
25
+ }
26
+ }
27
+
28
+ // Bare specifiers pi itself supplies to loaded extensions — never mirrored into dependencies.
29
+ const peerSupplied = new Set(Object.keys(pkg.peerDependencies ?? {}));
30
+
31
+ function isBuiltin(spec) {
32
+ return spec.startsWith("node:");
33
+ }
34
+ function isRelative(spec) {
35
+ return spec.startsWith(".") || spec.startsWith("/");
36
+ }
37
+ function packageName(spec) {
38
+ const parts = spec.split("/");
39
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
40
+ }
41
+
42
+ function collectTsFiles(dir) {
43
+ const out = [];
44
+ for (const name of readdirSync(dir)) {
45
+ const full = join(dir, name);
46
+ const st = statSync(full);
47
+ if (st.isDirectory()) out.push(...collectTsFiles(full));
48
+ else if (name.endsWith(".ts")) out.push(full);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // Scope the scan to the pi-subagents source only. Other shipped 3p sources reference SDK
54
+ // specifiers behind lazy `await import()` guards that are intentionally NOT root dependencies;
55
+ // scanning them would produce a permanently failing test.
56
+ const SCAN_DIR = join(PKG_DIR, "3p", "pi-subagents", "src");
57
+ if (!existsSync(SCAN_DIR)) {
58
+ fail(`expected shipped source dir missing: 3p/pi-subagents/src`);
59
+ process.exit(process.exitCode ?? 1);
60
+ }
61
+
62
+ // Value (non-type) import/export ... from "spec" and dynamic import("spec"). `import type` /
63
+ // `export type` statements are type-only and erased at build, so they carry no runtime requirement.
64
+ const FROM_RE = /(^|\n)\s*(import|export)\s+(?!type\b)(?:[^;]*?\sfrom\s+)?["']([^"']+)["']/g;
65
+ const DYN_RE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
66
+
67
+ const required = new Set();
68
+ for (const file of collectTsFiles(SCAN_DIR)) {
69
+ const src = readFileSync(file, "utf8");
70
+ for (const m of src.matchAll(FROM_RE)) addSpec(m[3]);
71
+ for (const m of src.matchAll(DYN_RE)) addSpec(m[1]);
72
+ }
73
+ function addSpec(spec) {
74
+ if (isBuiltin(spec) || isRelative(spec)) return;
75
+ const name = packageName(spec);
76
+ if (peerSupplied.has(name)) return;
77
+ required.add(name);
78
+ }
79
+
80
+ // Floor: these two are the runtime imports that broke consumer installs; assert them explicitly
81
+ // even if the extractor's shape ever changes.
82
+ required.add("croner");
83
+ required.add("nanoid");
84
+
85
+ const specs = [...required].sort();
86
+ console.log(` runtime specifiers to resolve: ${specs.join(", ")}`);
87
+
88
+ for (const spec of specs) {
89
+ try {
90
+ await import(spec);
91
+ console.log(` ✓ resolved: ${spec}`);
92
+ } catch (err) {
93
+ fail(`cannot resolve '${spec}' from installed package: ${err?.message ?? err}`);
94
+ }
95
+ }
96
+
97
+ if (process.exitCode) {
98
+ console.error(" → the root package is missing a runtime dependency its shipped extensions need.");
99
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bash
2
+ # Aggregates the test suites of the vendored 3p/ extensions, each run with its OWN native runner
3
+ # (vitest for pi-subagents/pi-tasks, bun for pi-lsp/pi-ask-user/pi-plannotator). The root
4
+ # `npm test` excludes 3p/**, so without this the fork-delta tests never gate CI.
5
+ #
6
+ # Exit non-zero if any package's suite fails. Runs every package even if an earlier one fails, so
7
+ # one invocation reports all breakage.
8
+ set -uo pipefail
9
+ cd "$(dirname "$0")/.."
10
+ ROOT="$(pwd)"
11
+
12
+ if ! command -v bun >/dev/null 2>&1; then
13
+ echo "ERROR: 'bun' is not on PATH — pi-lsp, pi-ask-user and pi-plannotator gate on it." >&2
14
+ echo "Install bun (https://bun.sh) and re-run, or run the vitest 3p suites individually." >&2
15
+ exit 127
16
+ fi
17
+
18
+ failures=()
19
+
20
+ run() {
21
+ local name="$1"; shift
22
+ local dir="$1"; shift
23
+ echo ""
24
+ echo "──────────────────────────────────────────────"
25
+ echo "▶ 3p/$name ($*)"
26
+ echo "──────────────────────────────────────────────"
27
+ ( cd "$ROOT/3p/$dir" && "$@" )
28
+ if [ $? -ne 0 ]; then
29
+ failures+=("$name")
30
+ fi
31
+ }
32
+
33
+ # vitest packages (use the package's own vitest.config.ts via npx vitest run).
34
+ run "pi-subagents" "pi-subagents" npx vitest run
35
+ run "pi-tasks" "pi-tasks" npx vitest run
36
+
37
+ # bun packages. pi-ask-user has no `test` npm script, so invoke `bun test` directly.
38
+ run "pi-lsp" "pi-lsp" bun test
39
+ run "pi-ask-user" "pi-ask-user" bun test
40
+
41
+ # pi-plannotator: scope explicitly to the shipped pi-extension app + the fork-parity guards.
42
+ # Bare `bun test` would reach unshipped upstream workspace packages that need alias setup.
43
+ run "pi-plannotator" "pi-plannotator" bun test apps/pi-extension tests/parity
44
+
45
+ echo ""
46
+ echo "──────────────────────────────────────────────"
47
+ if [ ${#failures[@]} -eq 0 ]; then
48
+ echo "✓ all 3p suites passed"
49
+ exit 0
50
+ fi
51
+ echo "✗ 3p suites FAILED: ${failures[*]}"
52
+ exit 1
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bash
2
+ # Release-layout smoke test: pack the root package, install the tarball into a throwaway
3
+ # directory OUTSIDE this checkout, and verify the shipped extension artifact resolves its
4
+ # runtime imports from the installed tree alone.
5
+ #
6
+ # Why this exists: the root `npm test` excludes 3p/** and the vendored suites run inside
7
+ # 3p/*/node_modules, so a bare import that a vendored extension needs but the root package
8
+ # forgets to declare is invisible in the dev checkout (its node_modules still has the package).
9
+ # It only surfaces after a clean `npm install` by a consumer — exactly the croner/nanoid
10
+ # regression this guards against. Resolving bare specifiers from the installed package tree is
11
+ # the only shape that reproduces that failure.
12
+ #
13
+ # Registry access is required (npm install of the tarball fetches deps, runs the root
14
+ # postinstall, and npm v7+ auto-installs peerDependencies). Offline CI: warm the npm cache and
15
+ # export a --prefer-offline-friendly registry mirror.
16
+ set -uo pipefail
17
+ cd "$(dirname "$0")/.."
18
+ ROOT="$(pwd)"
19
+
20
+ TMPDIR_SMOKE="$(mktemp -d)"
21
+ trap 'rm -rf "$TMPDIR_SMOKE"' EXIT
22
+
23
+ echo "▶ packing root package"
24
+ PACK_JSON="$(npm pack --json --pack-destination "$TMPDIR_SMOKE" 2>/dev/null)"
25
+ TARBALL="$(node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const a=JSON.parse(s);process.stdout.write(a[0].filename)})' <<<"$PACK_JSON")"
26
+ if [ -z "$TARBALL" ] || [ ! -f "$TMPDIR_SMOKE/$TARBALL" ]; then
27
+ echo "ERROR: npm pack did not produce a tarball (got: '$TARBALL')." >&2
28
+ exit 1
29
+ fi
30
+ echo " tarball: $TARBALL"
31
+
32
+ INSTALL_DIR="$TMPDIR_SMOKE/install"
33
+ mkdir -p "$INSTALL_DIR"
34
+ ( cd "$INSTALL_DIR" && npm init -y >/dev/null 2>&1 )
35
+
36
+ echo "▶ installing tarball into a clean consumer directory"
37
+ if ! ( cd "$INSTALL_DIR" && npm install "$TMPDIR_SMOKE/$TARBALL" --no-audit --no-fund 2>&1 ); then
38
+ echo "ERROR: installing the packed tarball failed." >&2
39
+ exit 1
40
+ fi
41
+
42
+ PKG_NAME="$(node -e 'process.stdout.write(require("./package.json").name)')"
43
+ PKG_DIR="$INSTALL_DIR/node_modules/$PKG_NAME"
44
+ if [ ! -d "$PKG_DIR" ]; then
45
+ echo "ERROR: installed package not found at $PKG_DIR" >&2
46
+ exit 1
47
+ fi
48
+
49
+ # Run the resolution check from a helper placed INSIDE the installed package, so bare-specifier
50
+ # resolution provably walks the artifact's own node_modules hierarchy (not this checkout's).
51
+ CHECK="$PKG_DIR/__smoke-resolve.mjs"
52
+ cp "$ROOT/scripts/lib/smoke-resolve.mjs" "$CHECK"
53
+
54
+ echo "▶ resolving shipped extension runtime imports from the install tree"
55
+ ( cd "$PKG_DIR" && node "$CHECK" )
56
+ STATUS=$?
57
+
58
+ if [ $STATUS -ne 0 ]; then
59
+ echo "✗ package smoke test FAILED" >&2
60
+ exit $STATUS
61
+ fi
62
+ echo "✓ package smoke test passed"