@ilya-lesikov/pi-pi 0.10.0 → 0.12.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 (61) hide show
  1. package/3p/pi-subagents/package.json +0 -1
  2. package/3p/pi-subagents/src/agent-manager.ts +1 -0
  3. package/3p/pi-subagents/src/index.ts +1 -0
  4. package/3p/pi-subagents/src/types.ts +8 -0
  5. package/extensions/orchestrator/agents/advisor.ts +2 -1
  6. package/extensions/orchestrator/agents/code-reviewer.ts +1 -0
  7. package/extensions/orchestrator/agents/constraints.test.ts +43 -10
  8. package/extensions/orchestrator/agents/constraints.ts +26 -5
  9. package/extensions/orchestrator/agents/deep-debugger.ts +7 -6
  10. package/extensions/orchestrator/agents/explore.ts +1 -1
  11. package/extensions/orchestrator/agents/librarian.ts +1 -1
  12. package/extensions/orchestrator/agents/planner.ts +1 -1
  13. package/extensions/orchestrator/agents/prompts.test.ts +135 -1
  14. package/extensions/orchestrator/agents/reviewer.ts +2 -2
  15. package/extensions/orchestrator/agents/task.ts +6 -2
  16. package/extensions/orchestrator/agents/tool-routing.ts +22 -6
  17. package/extensions/orchestrator/assumptions.test.ts +77 -0
  18. package/extensions/orchestrator/assumptions.ts +85 -0
  19. package/extensions/orchestrator/cbm.ts +4 -1
  20. package/extensions/orchestrator/cbm.which.test.ts +92 -0
  21. package/extensions/orchestrator/command-handlers.ts +9 -1
  22. package/extensions/orchestrator/config.test.ts +1 -3
  23. package/extensions/orchestrator/config.ts +3 -4
  24. package/extensions/orchestrator/context.test.ts +2 -7
  25. package/extensions/orchestrator/context.ts +3 -3
  26. package/extensions/orchestrator/doctor.test.ts +35 -0
  27. package/extensions/orchestrator/doctor.ts +4 -2
  28. package/extensions/orchestrator/event-handlers.more.test.ts +185 -0
  29. package/extensions/orchestrator/event-handlers.test.ts +22 -2
  30. package/extensions/orchestrator/event-handlers.ts +156 -18
  31. package/extensions/orchestrator/flant-infra.more.test.ts +55 -0
  32. package/extensions/orchestrator/flant-infra.test.ts +0 -3
  33. package/extensions/orchestrator/flant-infra.ts +17 -7
  34. package/extensions/orchestrator/integration.test.ts +355 -173
  35. package/extensions/orchestrator/orchestrator.ts +3 -9
  36. package/extensions/orchestrator/phases/brainstorm.test.ts +25 -7
  37. package/extensions/orchestrator/phases/brainstorm.ts +35 -71
  38. package/extensions/orchestrator/phases/implementation.test.ts +22 -0
  39. package/extensions/orchestrator/phases/implementation.ts +6 -0
  40. package/extensions/orchestrator/phases/machine.test.ts +0 -28
  41. package/extensions/orchestrator/phases/machine.ts +0 -38
  42. package/extensions/orchestrator/phases/planning.test.ts +27 -1
  43. package/extensions/orchestrator/phases/planning.ts +1 -1
  44. package/extensions/orchestrator/phases/review-task.test.ts +7 -0
  45. package/extensions/orchestrator/phases/review-task.ts +1 -1
  46. package/extensions/orchestrator/phases/review.test.ts +47 -10
  47. package/extensions/orchestrator/phases/review.ts +36 -25
  48. package/extensions/orchestrator/pp-menu.from.test.ts +65 -0
  49. package/extensions/orchestrator/pp-menu.leaves.test.ts +17 -0
  50. package/extensions/orchestrator/pp-menu.test.ts +106 -49
  51. package/extensions/orchestrator/pp-menu.ts +280 -134
  52. package/extensions/orchestrator/pp-state-tools.ts +1 -1
  53. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +2 -2
  54. package/extensions/orchestrator/rate-limit-fallback.ts +1 -1
  55. package/extensions/orchestrator/state.test.ts +17 -17
  56. package/extensions/orchestrator/state.ts +35 -12
  57. package/package.json +6 -3
  58. package/scripts/lib/smoke-resolve.mjs +99 -0
  59. package/scripts/postinstall.mjs +93 -0
  60. package/scripts/test-package.sh +62 -0
  61. package/scripts/postinstall.sh +0 -18
@@ -138,7 +138,7 @@ function validateStateContent(taskDir: string, absolute: string, content: string
138
138
  const res = validatePlan(content);
139
139
  return res.ok
140
140
  ? { ok: true }
141
- : { ok: false, errors: res.errors, hint: "Keep exactly: # Plan, ## Scope, ## Checklist (items need 'Done when:'), ## Blockers (optional)." };
141
+ : { ok: false, errors: res.errors, hint: "Keep exactly: # Plan, ## Scope, ## Checklist (items need 'Done when:'), ## Pattern constraints (optional), ## Blockers (optional)." };
142
142
  }
143
143
  return { ok: true };
144
144
  }
@@ -30,14 +30,14 @@ import { handleMainRateLimit, handleSubagentRateLimit } from "./rate-limit-fallb
30
30
 
31
31
  function makeOrchestrator() {
32
32
  return {
33
- active: { state: { phase: "debug" } },
33
+ active: { state: { phase: "implement" } },
34
34
  activeTaskToken: 1,
35
35
  subFallbackActive: false,
36
36
  subFallbackDialogPending: false,
37
37
  subFallbackPendingDecision: false,
38
38
  subFallbackModelId: null as string | null,
39
39
  subSwitchBackTimer: null as any,
40
- config: { agents: { orchestrators: { debug: { thinking: "high" }, implement: { thinking: "high" } } } },
40
+ config: { agents: { orchestrators: { implement: { thinking: "high" } } } },
41
41
  switchModel: vi.fn().mockResolvedValue(true),
42
42
  sendUserMessageWhenIdle: vi.fn(),
43
43
  cancelPendingRetry: vi.fn(),
@@ -160,7 +160,7 @@ async function activateFallback(
160
160
  function currentThinking(orchestrator: Orchestrator): string {
161
161
  const phase = orchestrator.active?.state.phase;
162
162
  const orchestrators = orchestrator.config?.agents?.orchestrators as Record<string, { thinking?: string }> | undefined;
163
- const key = phase === "debug" || phase === "brainstorm" || phase === "review" || phase === "quick" ? phase : "implement";
163
+ const key = phase === "brainstorm" || phase === "review" || phase === "quick" ? phase : "implement";
164
164
  return orchestrators?.[key]?.thinking ?? "high";
165
165
  }
166
166
 
@@ -52,15 +52,6 @@ describe("createTask", () => {
52
52
  expect(raw.endsWith("\n")).toBe(true);
53
53
  });
54
54
 
55
- it("uses debug as initial phase for debug", () => {
56
- const cwd = makeCwd();
57
- const taskDir = createTask(cwd, "debug", "Fix timeout issue");
58
- const state = loadTask(taskDir);
59
- expect(state.phase).toBe("debug");
60
- expect(state.step).toBe("llm_work");
61
- expect(state.reviewCycle).toBeNull();
62
- expect(state.reviewPass).toBe(0);
63
- });
64
55
 
65
56
  it("uses brainstorm as initial phase for brainstorm", () => {
66
57
  const cwd = makeCwd();
@@ -103,6 +94,16 @@ describe("loadTask", () => {
103
94
 
104
95
  expect(() => loadTask(taskDir)).toThrowError(/Failed to parse .*state\.json:/);
105
96
  });
97
+
98
+ it("throws for a legacy debug-phase task", () => {
99
+ const cwd = makeCwd();
100
+ const taskDir = createTask(cwd, "implement", "Legacy debug");
101
+ const state = loadTask(taskDir);
102
+ (state as any).phase = "debug";
103
+ saveTask(taskDir, state);
104
+
105
+ expect(() => loadTask(taskDir)).toThrowError(/Debug tasks are no longer supported/);
106
+ });
106
107
  });
107
108
 
108
109
  describe("saveTask", () => {
@@ -134,18 +135,18 @@ describe("listTasks", () => {
134
135
  it("lists only non-done tasks and respects type filter", () => {
135
136
  const cwd = makeCwd();
136
137
  const implementTask = createTask(cwd, "implement", "Implement feature");
137
- const debugTask = createTask(cwd, "debug", "Debug crash");
138
+ const reviewTask = createTask(cwd, "review", "Review crash");
138
139
  const brainstormTask = createTask(cwd, "brainstorm", "Idea storm");
139
140
 
140
- const doneState = loadTask(debugTask);
141
+ const doneState = loadTask(reviewTask);
141
142
  doneState.phase = "done";
142
- saveTask(debugTask, doneState);
143
+ saveTask(reviewTask, doneState);
143
144
 
144
145
  const all = listTasks(cwd);
145
146
  const allDirs = all.map((t) => t.dir);
146
147
  expect(allDirs).toContain(implementTask);
147
148
  expect(allDirs).toContain(brainstormTask);
148
- expect(allDirs).not.toContain(debugTask);
149
+ expect(allDirs).not.toContain(reviewTask);
149
150
 
150
151
  const brainstormOnly = listTasks(cwd, "brainstorm");
151
152
  expect(brainstormOnly).toHaveLength(1);
@@ -384,7 +385,7 @@ describe("getActiveTask", () => {
384
385
  it("returns null when multiple unlocked tasks exist (ambiguous)", () => {
385
386
  const cwd = makeCwd();
386
387
  createTask(cwd, "implement", "First abandoned");
387
- createTask(cwd, "debug", "Second abandoned");
388
+ createTask(cwd, "review", "Second abandoned");
388
389
  vi.spyOn(lockfile, "checkSync").mockReturnValue(false);
389
390
 
390
391
  expect(getActiveTask(cwd)).toBeNull();
@@ -393,7 +394,7 @@ describe("getActiveTask", () => {
393
394
  it("returns the single unlocked task when others are locked", () => {
394
395
  const cwd = makeCwd();
395
396
  const lockedTask = createTask(cwd, "implement", "Locked by other");
396
- const unlockedTask = createTask(cwd, "debug", "Abandoned");
397
+ const unlockedTask = createTask(cwd, "review", "Abandoned");
397
398
 
398
399
  vi.spyOn(lockfile, "checkSync").mockImplementation((path: string, _options?: { stale?: number }) => {
399
400
  if (path === join(lockedTask, "state.json")) return true;
@@ -434,7 +435,7 @@ describe("getActiveTaskStatus", () => {
434
435
  it("reports ambiguous with all tasks when multiple are unlocked", () => {
435
436
  const cwd = makeCwd();
436
437
  createTask(cwd, "implement", "First");
437
- createTask(cwd, "debug", "Second");
438
+ createTask(cwd, "review", "Second");
438
439
  vi.spyOn(lockfile, "checkSync").mockReturnValue(false);
439
440
 
440
441
  const status = getActiveTaskStatus(cwd);
@@ -447,7 +448,6 @@ describe("getFirstPhase", () => {
447
448
  it("returns expected first phase for each task type", () => {
448
449
  expect(getFirstPhase("implement")).toBe("brainstorm");
449
450
  expect(getFirstPhase("brainstorm")).toBe("brainstorm");
450
- expect(getFirstPhase("debug")).toBe("debug");
451
451
  expect(getFirstPhase("review")).toBe("review");
452
452
  expect(getFirstPhase("quick")).toBe("quick");
453
453
  });
@@ -28,14 +28,13 @@ export interface AutonomousConfig {
28
28
  phases: Record<string, AutonomousPhaseConfig>;
29
29
  }
30
30
 
31
- export type TaskType = "implement" | "debug" | "brainstorm" | "review" | "quick";
31
+ export type TaskType = "implement" | "brainstorm" | "review" | "quick";
32
32
 
33
33
  export type ImplementPhase = "brainstorm" | "plan" | "implement" | "done";
34
- export type DebugPhase = "debug" | "plan" | "implement" | "done";
35
34
  export type BrainstormPhase = "brainstorm" | "plan" | "implement" | "done";
36
35
  export type ReviewPhase = "review" | "plan" | "implement" | "done";
37
36
  export type QuickPhase = "quick";
38
- export type Phase = ImplementPhase | DebugPhase | BrainstormPhase | ReviewPhase | QuickPhase;
37
+ export type Phase = ImplementPhase | BrainstormPhase | ReviewPhase | QuickPhase;
39
38
 
40
39
  export interface TaskState {
41
40
  phase: Phase;
@@ -76,10 +75,24 @@ export interface TaskState {
76
75
  // the guided/autonomous transition or the autonomous terminal handoff), so the
77
76
  // hooks never run twice for the same completion.
78
77
  afterImplementRan?: boolean;
79
- // Per-repo interleaved Plannotator review cursor (#3a). repoPaths is the ordered
80
- // set of repos to review; index is the next repo. Persisted so the loop resumes
81
- // on the next /pp after the agent fixes one repo's feedback. Undefined = no loop.
82
- plannotatorCursor?: { repoPaths: string[]; index: number };
78
+ // State-freshness reconcile markers (see maybePromptReconcile). Absent on legacy
79
+ // tasks. reconcilePending forces a re-prompt after a pause/complete that could
80
+ // not reconcile in-line.
81
+ reconcilePromptedPhase?: string;
82
+ reconciledPhase?: string;
83
+ reconcilePending?: boolean;
84
+ // Per-repo Plannotator review cursor. repoPaths is the ordered set of repos to
85
+ // review; index is read only by the single-repo path (retained for
86
+ // backward-compatible deserialization of older cursors). For multi-repo reviews
87
+ // the user drives repo selection via a status picker; `status` records each
88
+ // repo's outcome (absent = unreviewed) and `feedback` holds pending
89
+ // changes-requested feedback not yet applied. Undefined = no active loop.
90
+ plannotatorCursor?: {
91
+ repoPaths: string[];
92
+ index: number;
93
+ status?: Record<string, "approved" | "changes-requested" | "fixes-applied">;
94
+ feedback?: Record<string, string>;
95
+ };
83
96
  // The phase the task was in when it was marked done. Recorded so the Resume
84
97
  // "reopen a done task" flow (#2) can restore the actual last working phase
85
98
  // (done itself carries no phase history). Absent on legacy done tasks.
@@ -102,7 +115,6 @@ function taskStatePath(taskDir: string): string {
102
115
 
103
116
  export function getFirstPhase(type: TaskType): Exclude<Phase, "done"> {
104
117
  if (type === "implement" || type === "brainstorm") return "brainstorm";
105
- if (type === "debug") return "debug";
106
118
  if (type === "review") return "review";
107
119
  return "quick";
108
120
  }
@@ -115,7 +127,7 @@ export function getEffectiveMode(state: TaskState): TaskMode | undefined {
115
127
  // drives them, so they never inherit the task's autonomous mode even when one is set.
116
128
  // plan/implement are autonomous-capable regardless of whether they are the task's initial phase.
117
129
  export function getEffectivePhaseMode(state: TaskState): TaskMode {
118
- if (state.phase === "brainstorm" || state.phase === "debug" || state.phase === "review") return "guided";
130
+ if (state.phase === "brainstorm" || state.phase === "review") return "guided";
119
131
  return getEffectiveMode(state) ?? "guided";
120
132
  }
121
133
 
@@ -162,8 +174,19 @@ export function createTask(cwd: string, type: TaskType, description: string, mod
162
174
  export function loadTask(taskDir: string): TaskState {
163
175
  const sp = taskStatePath(taskDir);
164
176
  const raw = readFileSync(sp, "utf-8");
177
+ let parsed: TaskState;
178
+ try {
179
+ parsed = JSON.parse(raw) as TaskState;
180
+ } catch (err: any) {
181
+ throw new Error(`Failed to parse ${sp}: ${err.message}`);
182
+ }
183
+ if ((parsed as any).phase === "debug") {
184
+ throw new Error(
185
+ "Debug tasks are no longer supported; use /pp → Implement → From to reuse their artifacts.",
186
+ );
187
+ }
165
188
  try {
166
- const state = JSON.parse(raw) as TaskState;
189
+ const state = parsed;
167
190
  if ((state as any).phase === "planning") {
168
191
  state.phase = "plan";
169
192
  }
@@ -203,7 +226,7 @@ export function listTasks(cwd: string, typeOrOptions?: TaskType | ListTasksOptio
203
226
  const base = stateDir(cwd);
204
227
  if (!existsSync(base)) return [];
205
228
 
206
- const types: TaskType[] = options.type ? [options.type] : ["implement", "debug", "brainstorm", "review", "quick"];
229
+ const types: TaskType[] = options.type ? [options.type] : ["implement", "brainstorm", "review", "quick"];
207
230
  const results: TaskInfo[] = [];
208
231
 
209
232
  for (const t of types) {
@@ -343,7 +366,7 @@ function firstMarkdownContent(path: string): string | null {
343
366
  export function taskFullName(taskDir: string, state: TaskState): string {
344
367
  let desc = state.description ?? "";
345
368
 
346
- if (["implement", "debug", "brainstorm", "review", "quick"].includes(desc)) {
369
+ if (["implement", "brainstorm", "review", "quick"].includes(desc)) {
347
370
  const fallback =
348
371
  firstMarkdownContent(join(taskDir, "USER_REQUEST.md")) ??
349
372
  firstMarkdownContent(join(taskDir, "RESEARCH.md")) ??
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilya-lesikov/pi-pi",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Pi-Pi Coding Agent: based on Pi Coding Agent, but twice as good",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -33,11 +33,12 @@
33
33
  "AGENTS.md"
34
34
  ],
35
35
  "scripts": {
36
- "postinstall": "bash scripts/postinstall.sh",
36
+ "postinstall": "node scripts/postinstall.mjs",
37
37
  "test": "vitest run --exclude '3p/**'",
38
38
  "test:watch": "vitest --exclude '3p/**'",
39
39
  "test:3p": "bash scripts/test-3p.sh",
40
- "test:all": "vitest run --exclude '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",
41
42
  "coverage": "vitest run --exclude '3p/**' --coverage"
42
43
  },
43
44
  "pi": {
@@ -53,8 +54,10 @@
53
54
  "dependencies": {
54
55
  "@joplin/turndown-plugin-gfm": "^1.0.64",
55
56
  "@pierre/diffs": "^1.2.0",
57
+ "croner": "^10.0.1",
56
58
  "effect": "^3.0.0",
57
59
  "minimatch": "^10.0.0",
60
+ "nanoid": "^5.0.0",
58
61
  "pino": "^10.3.1",
59
62
  "proper-lockfile": "^4.1.2",
60
63
  "turndown": "^7.2.0",
@@ -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,93 @@
1
+ // Vendor-source decision: on non-Windows we invoke the existing bash `vendor.sh` UNCHANGED so
2
+ // Linux/CI stay byte-for-byte identical; on Windows we SKIP the bash vendor step (a documented
3
+ // Windows limitation) and rely on the npm-pack HTML fallback below. vendor.sh is still the single
4
+ // source used by `npm run build`/CI — this script does not fork its logic.
5
+ import { execFileSync, spawnSync } from "node:child_process";
6
+ import { existsSync, mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { gunzipSync } from "node:zlib";
8
+ import { tmpdir } from "node:os";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
13
+ const REPO_ROOT = resolve(SCRIPT_DIR, "..");
14
+ const PI_EXT_DIR = join(REPO_ROOT, "3p", "pi-plannotator", "apps", "pi-extension");
15
+ const isWindows = process.platform === "win32";
16
+
17
+ function fail(msg) {
18
+ console.error(` ✗ ${msg}`);
19
+ process.exit(1);
20
+ }
21
+
22
+ function runVendor() {
23
+ if (isWindows) {
24
+ console.log(" ⚠ skipping plannotator vendor step on Windows (bash vendor.sh unavailable)");
25
+ return;
26
+ }
27
+ const res = spawnSync("bash", [join(PI_EXT_DIR, "vendor.sh")], { cwd: REPO_ROOT, stdio: "inherit" });
28
+ if (res.status !== 0) fail(`vendor.sh failed with exit code ${res.status ?? "unknown"}`);
29
+ }
30
+
31
+ function htmlPresent() {
32
+ return existsSync(join(PI_EXT_DIR, "plannotator.html")) && existsSync(join(PI_EXT_DIR, "review-editor.html"));
33
+ }
34
+
35
+ // Extract a specific file from an npm tarball (gzipped POSIX/ustar tar) using only
36
+ // Node builtins — no external `tar` executable, which is not guaranteed on native
37
+ // Windows. Returns the file's bytes, or null when the entry is absent.
38
+ function extractFromTar(tarBytes, wantPath) {
39
+ let off = 0;
40
+ while (off + 512 <= tarBytes.length) {
41
+ const header = tarBytes.subarray(off, off + 512);
42
+ const name = header.subarray(0, 100).toString("utf-8").replace(/\0.*$/, "");
43
+ if (name === "") break;
44
+ const size = parseInt(header.subarray(124, 136).toString("utf-8").replace(/\0.*$/, "").trim() || "0", 8);
45
+ const dataStart = off + 512;
46
+ if (name === wantPath) return tarBytes.subarray(dataStart, dataStart + size);
47
+ off = dataStart + Math.ceil(size / 512) * 512;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ // Run `npm pack` without depending on PATH/PATHEXT resolution of `npm`, which on
53
+ // Windows is `npm.cmd` and cannot be spawned by execFileSync without a shell
54
+ // (Node rejects .cmd without shell:true). In a lifecycle script npm exports
55
+ // npm_execpath (the CLI's JS entry); run it with the current node. Fall back to a
56
+ // shell-resolved `npm`/`npm.cmd` only if that env var is somehow absent.
57
+ function runNpmPack(destDir) {
58
+ const npmCli = process.env.npm_execpath;
59
+ if (npmCli) {
60
+ execFileSync(process.execPath, [npmCli, "pack", "@plannotator/pi-extension", "--pack-destination", destDir, "--silent"], {
61
+ stdio: ["ignore", "ignore", "ignore"],
62
+ });
63
+ return;
64
+ }
65
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
66
+ execFileSync(npmCmd, ["pack", "@plannotator/pi-extension", "--pack-destination", destDir, "--silent"], {
67
+ stdio: ["ignore", "ignore", "ignore"],
68
+ shell: true,
69
+ });
70
+ }
71
+
72
+ function extractHtmlFallback() {
73
+ const tmp = mkdtempSync(join(tmpdir(), "pi-pi-plannotator-"));
74
+ try {
75
+ runNpmPack(tmp);
76
+ const tgz = readdirSync(tmp).find((name) => name.endsWith(".tgz"));
77
+ if (!tgz) fail("npm pack produced no tarball for @plannotator/pi-extension");
78
+ const tarBytes = gunzipSync(readFileSync(join(tmp, tgz)));
79
+ for (const file of ["plannotator.html", "review-editor.html"]) {
80
+ const bytes = extractFromTar(tarBytes, `package/${file}`);
81
+ if (!bytes) fail(`@plannotator/pi-extension tarball is missing ${file}`);
82
+ writeFileSync(join(PI_EXT_DIR, file), bytes);
83
+ }
84
+ } finally {
85
+ rmSync(tmp, { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ runVendor();
90
+
91
+ if (htmlPresent()) process.exit(0);
92
+
93
+ extractHtmlFallback();
@@ -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"
@@ -1,18 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- cd "$(dirname "$0")/.."
4
-
5
- bash 3p/pi-plannotator/apps/pi-extension/vendor.sh
6
-
7
- PI_EXT_DIR="3p/pi-plannotator/apps/pi-extension"
8
- if [ -f "$PI_EXT_DIR/plannotator.html" ] && [ -f "$PI_EXT_DIR/review-editor.html" ]; then
9
- exit 0
10
- fi
11
-
12
- TMPDIR=$(mktemp -d)
13
- trap 'rm -rf "$TMPDIR"' EXIT
14
-
15
- npm pack @plannotator/pi-extension --pack-destination "$TMPDIR" --silent 2>/dev/null
16
- tar -xzf "$TMPDIR"/*.tgz -C "$TMPDIR"
17
- cp "$TMPDIR/package/plannotator.html" "$PI_EXT_DIR/plannotator.html"
18
- cp "$TMPDIR/package/review-editor.html" "$PI_EXT_DIR/review-editor.html"