xtrm-cli 0.5.0 → 0.5.27

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 (50) hide show
  1. package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.combined.log +17 -0
  2. package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stderr.log +0 -0
  3. package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stdout.log +17 -0
  4. package/dist/index.cjs +969 -1059
  5. package/dist/index.cjs.map +1 -1
  6. package/package.json +1 -1
  7. package/src/commands/clean.ts +7 -6
  8. package/src/commands/debug.ts +255 -0
  9. package/src/commands/docs.ts +180 -0
  10. package/src/commands/help.ts +92 -171
  11. package/src/commands/init.ts +9 -32
  12. package/src/commands/install-pi.ts +9 -16
  13. package/src/commands/install.ts +150 -2
  14. package/src/commands/pi-install.ts +10 -44
  15. package/src/core/context.ts +4 -52
  16. package/src/core/diff.ts +3 -16
  17. package/src/core/preflight.ts +0 -1
  18. package/src/index.ts +7 -4
  19. package/src/types/config.ts +0 -2
  20. package/src/utils/config-injector.ts +3 -3
  21. package/src/utils/pi-extensions.ts +41 -0
  22. package/src/utils/worktree-session.ts +86 -50
  23. package/test/extensions/beads-claim-lifecycle.test.ts +93 -0
  24. package/test/extensions/beads-parity.test.ts +94 -0
  25. package/test/extensions/extension-harness.ts +5 -5
  26. package/test/extensions/quality-gates-parity.test.ts +89 -0
  27. package/test/extensions/session-flow.test.ts +91 -0
  28. package/test/extensions/xtrm-loader.test.ts +38 -20
  29. package/test/install-pi.test.ts +22 -11
  30. package/test/pi-extensions.test.ts +50 -0
  31. package/test/session-launcher.test.ts +28 -38
  32. package/extensions/beads.ts +0 -109
  33. package/extensions/core/adapter.ts +0 -45
  34. package/extensions/core/lib.ts +0 -3
  35. package/extensions/core/logger.ts +0 -45
  36. package/extensions/core/runner.ts +0 -71
  37. package/extensions/custom-footer.ts +0 -160
  38. package/extensions/main-guard-post-push.ts +0 -44
  39. package/extensions/main-guard.ts +0 -126
  40. package/extensions/minimal-mode.ts +0 -201
  41. package/extensions/quality-gates.ts +0 -67
  42. package/extensions/service-skills.ts +0 -150
  43. package/extensions/xtrm-loader.ts +0 -89
  44. package/hooks/gitnexus-impact-reminder.py +0 -13
  45. package/src/commands/finish.ts +0 -25
  46. package/src/core/session-state.ts +0 -139
  47. package/src/core/xtrm-finish.ts +0 -267
  48. package/src/tests/session-flow-parity.test.ts +0 -118
  49. package/src/tests/session-state.test.ts +0 -124
  50. package/src/tests/xtrm-finish.test.ts +0 -148
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { ExtensionHarness } from "./extension-harness";
3
+ import beadsExtension from "../../../config/pi/extensions/beads/index";
4
+ import { SubprocessRunner } from "../../../config/pi/extensions/core/lib";
5
+ import * as fs from "node:fs";
6
+
7
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
8
+ isToolCallEventType: (name: string, event: any) => event?.toolName === name,
9
+ isBashToolResult: (event: any) => event?.toolName === "bash",
10
+ }));
11
+
12
+ vi.mock("../../../config/pi/extensions/core/lib", async () => {
13
+ const actual = await vi.importActual<any>("../../../config/pi/extensions/core/lib");
14
+ return {
15
+ ...actual,
16
+ SubprocessRunner: {
17
+ run: vi.fn(),
18
+ },
19
+ EventAdapter: {
20
+ isBeadsProject: vi.fn(() => true),
21
+ isMutatingFileTool: vi.fn((event: any) => event?.toolName === "write"),
22
+ parseBdCounts: vi.fn(() => ({ open: 1, inProgress: 0 })),
23
+ },
24
+ };
25
+ });
26
+
27
+ vi.mock("node:fs", () => ({
28
+ existsSync: vi.fn(() => false),
29
+ unlinkSync: vi.fn(),
30
+ }));
31
+
32
+ describe("Pi beads extension parity", () => {
33
+ let harness: ExtensionHarness;
34
+
35
+ beforeEach(() => {
36
+ vi.resetAllMocks();
37
+ harness = new ExtensionHarness();
38
+ harness.pi.sendUserMessage = vi.fn();
39
+ });
40
+
41
+ it("stores closed-this-session marker on successful bd close", async () => {
42
+ const calls: string[][] = [];
43
+ (SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
44
+ calls.push(args);
45
+ return { code: 0, stdout: "", stderr: "" };
46
+ });
47
+
48
+ beadsExtension(harness.pi);
49
+
50
+ const result = await harness.emit("tool_result", {
51
+ toolName: "bash",
52
+ input: { command: "bd close xtrm-777 --reason done" },
53
+ content: [{ type: "text", text: "closed" }],
54
+ isError: false,
55
+ });
56
+
57
+ expect(calls.some((a) => a[0] === "kv" && a[1] === "set" && a[2].startsWith("closed-this-session:"))).toBe(true);
58
+ expect(result?.content?.[1]?.text).toContain("Beads Insight");
59
+ });
60
+
61
+ it("fires memory gate once per closed marker and does not loop", async () => {
62
+ (SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
63
+ if (args[0] === "kv" && args[1] === "get" && `${args[2]}`.startsWith("closed-this-session:")) {
64
+ return { code: 0, stdout: "xtrm-123\n", stderr: "" };
65
+ }
66
+ return { code: 1, stdout: "", stderr: "" };
67
+ });
68
+
69
+ beadsExtension(harness.pi);
70
+
71
+ await harness.emit("agent_end", { messages: [] });
72
+ await harness.emit("agent_end", { messages: [] });
73
+
74
+ expect(harness.pi.sendUserMessage).toHaveBeenCalledTimes(1);
75
+ expect(harness.pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("claim `xtrm-123` was closed this session"));
76
+ });
77
+
78
+ it("consumes .memory-gate-done marker and clears session markers", async () => {
79
+ (fs.existsSync as any).mockReturnValue(true);
80
+ const calls: string[][] = [];
81
+ (SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
82
+ calls.push(args);
83
+ return { code: 0, stdout: "", stderr: "" };
84
+ });
85
+
86
+ beadsExtension(harness.pi);
87
+ await harness.emit("agent_end", { messages: [] });
88
+
89
+ expect(fs.unlinkSync).toHaveBeenCalled();
90
+ expect(calls.some((a) => a[0] === "kv" && a[1] === "clear" && `${a[2]}`.startsWith("claimed:"))).toBe(true);
91
+ expect(calls.some((a) => a[0] === "kv" && a[1] === "clear" && `${a[2]}`.startsWith("closed-this-session:"))).toBe(true);
92
+ expect(harness.pi.sendUserMessage).not.toHaveBeenCalled();
93
+ });
94
+ });
@@ -71,14 +71,14 @@ export class ExtensionHarness {
71
71
 
72
72
  async emit(event: string, data: any) {
73
73
  if (this.handlers[event]) {
74
- let lastResult: any = undefined;
74
+ let lastResult: any = undefined;
75
75
  for (const handler of this.handlers[event]) {
76
- console.log("EXECUTING HANDLER"); const res = await handler(data, this.ctx); console.log("HANDLER RESULT", res);
76
+ const res = await handler(data, this.ctx);
77
77
  if (res !== undefined) {
78
- lastResult = res; console.log("EMIT FOUND RESULT", res);
79
- }
78
+ lastResult = res;
79
+ }
80
80
  }
81
- return lastResult;
81
+ return lastResult;
82
82
  }
83
83
  return undefined;
84
84
  }
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { ExtensionHarness } from "./extension-harness";
3
+ import qualityGatesExtension from "../../../config/pi/extensions/quality-gates/index";
4
+ import { SubprocessRunner } from "../../../config/pi/extensions/core/lib";
5
+ import * as fs from "node:fs";
6
+
7
+ vi.mock("../../../config/pi/extensions/core/lib", async () => {
8
+ const actual = await vi.importActual<any>("../../../config/pi/extensions/core/lib");
9
+ return {
10
+ ...actual,
11
+ SubprocessRunner: {
12
+ run: vi.fn(),
13
+ },
14
+ EventAdapter: {
15
+ isMutatingFileTool: vi.fn((event: any) => event.toolName === "write" || event.toolName === "edit"),
16
+ extractPathFromToolInput: vi.fn((event: any) => event.input.path),
17
+ },
18
+ };
19
+ });
20
+
21
+ vi.mock("node:fs", () => ({
22
+ existsSync: vi.fn(() => true),
23
+ }));
24
+
25
+ describe("Pi quality-gates extension parity", () => {
26
+ let harness: ExtensionHarness;
27
+
28
+ beforeEach(() => {
29
+ vi.resetAllMocks();
30
+ harness = new ExtensionHarness();
31
+ });
32
+
33
+ it("runs JS gate for .cjs files and reports stdout details", async () => {
34
+ (SubprocessRunner.run as any).mockResolvedValue({
35
+ code: 0,
36
+ stdout: "ESLint auto-fixed issues",
37
+ stderr: "",
38
+ });
39
+
40
+ qualityGatesExtension(harness.pi);
41
+
42
+ const result = await harness.emit("tool_result", {
43
+ toolName: "write",
44
+ input: { path: "hooks/sample.cjs" },
45
+ content: [{ type: "text", text: "ok" }],
46
+ });
47
+
48
+ expect(SubprocessRunner.run).toHaveBeenCalledWith(
49
+ "node",
50
+ expect.arrayContaining([expect.stringContaining("quality-check.cjs")]),
51
+ expect.any(Object),
52
+ );
53
+ expect(result?.content?.[1]?.text).toContain("ESLint auto-fixed issues");
54
+ });
55
+
56
+ it("fails tool result when hook exits with status 2", async () => {
57
+ (SubprocessRunner.run as any).mockResolvedValue({
58
+ code: 2,
59
+ stdout: "",
60
+ stderr: "Compilation failed: error TS1234",
61
+ });
62
+
63
+ qualityGatesExtension(harness.pi);
64
+
65
+ const result = await harness.emit("tool_result", {
66
+ toolName: "write",
67
+ input: { path: "src/main.ts" },
68
+ content: [{ type: "text", text: "Original content" }],
69
+ });
70
+
71
+ expect(result?.isError).toBe(true);
72
+ expect(result?.content?.[1]?.text).toContain("Compilation failed");
73
+ expect(harness.ctx.ui.notify).toHaveBeenCalledWith("Quality Gate failed for main.ts", "error");
74
+ });
75
+
76
+ it("no-ops when hook script is missing", async () => {
77
+ (fs.existsSync as any).mockReturnValue(false);
78
+ qualityGatesExtension(harness.pi);
79
+
80
+ const result = await harness.emit("tool_result", {
81
+ toolName: "write",
82
+ input: { path: "src/main.ts" },
83
+ content: [{ type: "text", text: "Original content" }],
84
+ });
85
+
86
+ expect(result).toBeUndefined();
87
+ expect(SubprocessRunner.run).not.toHaveBeenCalled();
88
+ });
89
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { ExtensionHarness } from "./extension-harness";
3
+ import sessionFlowExtension from "../../../config/pi/extensions/session-flow/index";
4
+ import { SubprocessRunner } from "../../../config/pi/extensions/core/lib";
5
+
6
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
7
+ isBashToolResult: (event: any) => event?.toolName === "bash",
8
+ }));
9
+
10
+ vi.mock("../../../config/pi/extensions/core/lib", async () => {
11
+ const actual = await vi.importActual<any>("../../../config/pi/extensions/core/lib");
12
+ return {
13
+ ...actual,
14
+ SubprocessRunner: {
15
+ run: vi.fn(),
16
+ },
17
+ EventAdapter: {
18
+ isBeadsProject: vi.fn(() => true),
19
+ },
20
+ };
21
+ });
22
+
23
+ describe("Pi session-flow extension", () => {
24
+ let harness: ExtensionHarness;
25
+
26
+ beforeEach(() => {
27
+ vi.resetAllMocks();
28
+ harness = new ExtensionHarness();
29
+ harness.pi.sendUserMessage = vi.fn();
30
+ });
31
+
32
+ it("adds claim-sync context on bd update --claim", async () => {
33
+ sessionFlowExtension(harness.pi);
34
+
35
+ const result = await harness.emit("tool_result", {
36
+ toolName: "bash",
37
+ input: { command: "bd update xtrm-123 --claim" },
38
+ content: [{ type: "text", text: "ok" }],
39
+ });
40
+
41
+ expect(result?.content?.[1]?.text).toContain("claimed xtrm-123");
42
+ });
43
+
44
+ it("does not trigger follow-up turns on agent_end (prevents stop-loop)", async () => {
45
+ (SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
46
+ if (args[0] === "kv" && args[1] === "get") {
47
+ return { code: 0, stdout: "xtrm-123\n", stderr: "" };
48
+ }
49
+ if (args[0] === "show") {
50
+ return { code: 0, stdout: JSON.stringify({ id: "xtrm-123", status: "in_progress" }), stderr: "" };
51
+ }
52
+ return { code: 0, stdout: "", stderr: "" };
53
+ });
54
+
55
+ sessionFlowExtension(harness.pi);
56
+
57
+ await harness.emit("agent_end", { messages: [] });
58
+ await harness.emit("agent_end", { messages: [] });
59
+
60
+ expect(harness.pi.sendUserMessage).not.toHaveBeenCalled();
61
+ expect(harness.ctx.ui.notify).toHaveBeenCalledTimes(1);
62
+ expect(harness.ctx.ui.notify).toHaveBeenCalledWith(
63
+ expect.stringContaining("bd close xtrm-123"),
64
+ "warning",
65
+ );
66
+ });
67
+
68
+ it("reminds about xt end only once per worktree", async () => {
69
+ harness.ctx.cwd = "/repo/.xtrm/worktrees/demo";
70
+ (SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
71
+ if (args[0] === "kv" && args[1] === "get") {
72
+ return { code: 0, stdout: "xtrm-999\n", stderr: "" };
73
+ }
74
+ if (args[0] === "show") {
75
+ return { code: 0, stdout: JSON.stringify({ id: "xtrm-999", status: "closed" }), stderr: "" };
76
+ }
77
+ return { code: 0, stdout: "", stderr: "" };
78
+ });
79
+
80
+ sessionFlowExtension(harness.pi);
81
+
82
+ await harness.emit("agent_end", { messages: [] });
83
+ await harness.emit("agent_end", { messages: [] });
84
+
85
+ expect(harness.ctx.ui.notify).toHaveBeenCalledTimes(1);
86
+ expect(harness.ctx.ui.notify).toHaveBeenCalledWith(
87
+ "Run `xt end` to create a PR and clean up this worktree.",
88
+ "info",
89
+ );
90
+ });
91
+ });
@@ -1,12 +1,19 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import { ExtensionHarness } from "./extension-harness";
3
- import xtrmLoaderExtension from "../../extensions/xtrm-loader";
3
+ import xtrmLoaderExtension from "../../../config/pi/extensions/xtrm-loader/index";
4
4
  import * as fs from "node:fs";
5
5
 
6
+ vi.mock("node:os", () => ({
7
+ homedir: () => "/home/test",
8
+ }));
9
+
6
10
  vi.mock("node:fs", () => ({
7
11
  existsSync: vi.fn(),
8
12
  readFileSync: vi.fn(),
9
13
  readdirSync: vi.fn(),
14
+ promises: {
15
+ readFile: vi.fn(),
16
+ },
10
17
  }));
11
18
 
12
19
  describe("XTRM Loader Extension", () => {
@@ -14,40 +21,51 @@ describe("XTRM Loader Extension", () => {
14
21
 
15
22
  beforeEach(() => {
16
23
  vi.resetAllMocks();
17
- harness = new ExtensionHarness();
24
+ harness = new ExtensionHarness("/workspace/project");
25
+ (fs.readdirSync as any).mockReturnValue([]);
18
26
  });
19
27
 
20
- it("should load project roadmap and rules", async () => {
28
+ it("injects using-xtrm content into system prompt at before_agent_start", async () => {
21
29
  (fs.existsSync as any).mockImplementation((p: string) => {
22
- if (p.endsWith("ROADMAP.md")) return true;
23
- if (p.endsWith(".claude/rules")) return true;
30
+ if (p === "/home/test/.agents/skills/using-xtrm/SKILL.md") return true;
31
+ if (p.endsWith("ROADMAP.md")) return false;
32
+ if (p.endsWith(".claude/rules")) return false;
24
33
  if (p.endsWith(".claude/skills")) return false;
25
34
  return false;
26
35
  });
27
36
 
28
37
  (fs.readFileSync as any).mockImplementation((p: string) => {
29
- if (p.endsWith("ROADMAP.md")) return "My Roadmap Content";
30
- if (p.endsWith("rule1.md")) return "Rule 1 Content";
38
+ if (p === "/home/test/.agents/skills/using-xtrm/SKILL.md") {
39
+ return "---\nname: using-xtrm\n---\n# Manual\nUse bd prime";
40
+ }
31
41
  return "";
32
42
  });
33
43
 
34
- (fs.readdirSync as any).mockImplementation((p: string) => {
35
- if (p.endsWith(".claude/rules")) return [{ name: "rule1.md", isFile: () => true, isDirectory: () => false }];
36
- return [];
37
- });
38
-
39
44
  xtrmLoaderExtension(harness.pi);
40
-
41
- // Trigger session_start to load data
42
45
  await harness.emit("session_start", {});
46
+ const result = await harness.emit("before_agent_start", { systemPrompt: "Base prompt" });
47
+
48
+ expect(result?.systemPrompt).toContain("XTRM Session Operating Manual");
49
+ expect(result?.systemPrompt).toContain("Use bd prime");
50
+ expect(result?.systemPrompt).not.toContain("name: using-xtrm");
51
+ });
43
52
 
44
- // Trigger before_agent_start to see injection
45
- const result = await harness.emit("before_agent_start", {
46
- systemPrompt: "Base prompt"
53
+ it("falls back to ~/.pi/agent/skills when ~/.agents path is missing", async () => {
54
+ (fs.existsSync as any).mockImplementation((p: string) => {
55
+ if (p === "/home/test/.agents/skills/using-xtrm/SKILL.md") return false;
56
+ if (p === "/home/test/.pi/agent/skills/using-xtrm/SKILL.md") return true;
57
+ if (p.endsWith("ROADMAP.md")) return false;
58
+ if (p.endsWith(".claude/rules")) return false;
59
+ if (p.endsWith(".claude/skills")) return false;
60
+ return false;
47
61
  });
48
62
 
49
- expect(result.systemPrompt).toContain("My Roadmap Content");
50
- expect(result.systemPrompt).toContain("Rule 1 Content");
51
- expect(harness.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("context and skills indexed"), "info");
63
+ (fs.readFileSync as any).mockReturnValue("# Manual\nPi fallback path");
64
+
65
+ xtrmLoaderExtension(harness.pi);
66
+ await harness.emit("session_start", {});
67
+ const result = await harness.emit("before_agent_start", { systemPrompt: "Base" });
68
+
69
+ expect(result?.systemPrompt).toContain("Pi fallback path");
52
70
  });
53
71
  });
@@ -107,12 +107,15 @@ describe('createInstallPiCommand', () => {
107
107
  expect(keys).toContain('qwen-cli');
108
108
  });
109
109
 
110
- it('extensions directory contains all expected .ts files', () => {
110
+ it('extensions directory contains expected extension package directories', () => {
111
111
  const fs = require('node:fs');
112
112
  const p = require('node:path');
113
113
  const extDir = p.resolve(__dirname, '..', '..', 'config', 'pi', 'extensions');
114
- const files = ['auto-session-name.ts','auto-update.ts','bg-process.ts','compact-header.ts','custom-footer.ts','git-checkpoint.ts','todo.ts'];
115
- for (const f of files) expect(fs.existsSync(p.join(extDir, f))).toBe(true);
114
+ const packages = ['auto-session-name', 'auto-update', 'compact-header', 'custom-footer', 'git-checkpoint', 'quality-gates', 'beads', 'session-flow', 'service-skills', 'xtrm-loader', 'plan-mode'];
115
+ for (const pkg of packages) {
116
+ expect(fs.existsSync(p.join(extDir, pkg, 'index.ts'))).toBe(true);
117
+ expect(fs.existsSync(p.join(extDir, pkg, 'package.json'))).toBe(true);
118
+ }
116
119
  });
117
120
 
118
121
  it('custom-provider-qwen-cli extension has index.ts and package.json', () => {
@@ -155,7 +158,7 @@ describe('createInstallPiCommand', () => {
155
158
  expect(result['DASHSCOPE_API_KEY']).toBe('sk-from-models-789');
156
159
  });
157
160
 
158
- it('diffPiExtensions reports missing and stale files', async () => {
161
+ it('diffPiExtensions reports missing and stale extension packages', async () => {
159
162
  const { diffPiExtensions } = await import('../src/commands/install-pi.js?t=diff' + Date.now());
160
163
  const nodeFs = require('node:fs');
161
164
  const nodePath = require('node:path');
@@ -164,17 +167,25 @@ describe('createInstallPiCommand', () => {
164
167
  const srcDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-ext-src-'));
165
168
  const dstDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-ext-dst-'));
166
169
 
167
- nodeFs.writeFileSync(nodePath.join(srcDir, 'a.ts'), 'export const a = 1;');
168
- nodeFs.writeFileSync(nodePath.join(srcDir, 'b.ts'), 'export const b = 1;');
169
- nodeFs.writeFileSync(nodePath.join(dstDir, 'a.ts'), 'export const a = 2;');
170
+ nodeFs.mkdirSync(nodePath.join(srcDir, 'a'));
171
+ nodeFs.writeFileSync(nodePath.join(srcDir, 'a', 'package.json'), JSON.stringify({ name: 'a' }));
172
+ nodeFs.writeFileSync(nodePath.join(srcDir, 'a', 'index.ts'), 'export const a = 1;');
173
+
174
+ nodeFs.mkdirSync(nodePath.join(srcDir, 'b'));
175
+ nodeFs.writeFileSync(nodePath.join(srcDir, 'b', 'package.json'), JSON.stringify({ name: 'b' }));
176
+ nodeFs.writeFileSync(nodePath.join(srcDir, 'b', 'index.ts'), 'export const b = 1;');
177
+
178
+ nodeFs.mkdirSync(nodePath.join(dstDir, 'a'));
179
+ nodeFs.writeFileSync(nodePath.join(dstDir, 'a', 'package.json'), JSON.stringify({ name: 'a' }));
180
+ nodeFs.writeFileSync(nodePath.join(dstDir, 'a', 'index.ts'), 'export const a = 2;');
170
181
 
171
182
  const diff = await diffPiExtensions(srcDir, dstDir);
172
183
 
173
- expect(diff.missing).toContain('b.ts');
174
- expect(diff.stale).toContain('a.ts');
184
+ expect(diff.missing).toContain('b');
185
+ expect(diff.stale).toContain('a');
175
186
 
176
- nodeFs.rmSync(srcDir, { recursive: true });
177
- nodeFs.rmSync(dstDir, { recursive: true });
187
+ nodeFs.rmSync(srcDir, { recursive: true, force: true });
188
+ nodeFs.rmSync(dstDir, { recursive: true, force: true });
178
189
  });
179
190
 
180
191
  it('createInstallPiCommand supports --check flag', () => {
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { syncManagedPiExtensions } from '../src/utils/pi-extensions.js';
6
+
7
+ describe('syncManagedPiExtensions', () => {
8
+ it('copies extension packages and reports package count', async () => {
9
+ const srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ext-src-'));
10
+ const dstRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ext-dst-'));
11
+
12
+ fs.mkdirSync(path.join(srcRoot, 'plan-mode'));
13
+ fs.writeFileSync(path.join(srcRoot, 'plan-mode', 'package.json'), '{}');
14
+ fs.writeFileSync(path.join(srcRoot, 'plan-mode', 'index.ts'), 'export default {};');
15
+ fs.mkdirSync(path.join(srcRoot, 'beads'));
16
+ fs.writeFileSync(path.join(srcRoot, 'beads', 'package.json'), '{}');
17
+
18
+ const count = await syncManagedPiExtensions({
19
+ sourceDir: srcRoot,
20
+ targetDir: dstRoot,
21
+ });
22
+
23
+ expect(count).toBe(2);
24
+ expect(fs.existsSync(path.join(dstRoot, 'plan-mode', 'index.ts'))).toBe(true);
25
+
26
+ fs.rmSync(srcRoot, { recursive: true, force: true });
27
+ fs.rmSync(dstRoot, { recursive: true, force: true });
28
+ });
29
+
30
+ it('supports dry-run without writing files', async () => {
31
+ const srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ext-src-'));
32
+ const dstRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ext-dst-'));
33
+ fs.mkdirSync(path.join(srcRoot, 'quality-gates'));
34
+
35
+ const logs: string[] = [];
36
+ const count = await syncManagedPiExtensions({
37
+ sourceDir: srcRoot,
38
+ targetDir: dstRoot,
39
+ dryRun: true,
40
+ log: (message) => logs.push(message),
41
+ });
42
+
43
+ expect(count).toBe(1);
44
+ expect(fs.existsSync(path.join(dstRoot, 'quality-gates'))).toBe(false);
45
+ expect(logs.some((message) => message.includes('[DRY RUN]'))).toBe(true);
46
+
47
+ fs.rmSync(srcRoot, { recursive: true, force: true });
48
+ fs.rmSync(dstRoot, { recursive: true, force: true });
49
+ });
50
+ });
@@ -82,58 +82,48 @@ describe('session launcher CLI surface (2q8j)', () => {
82
82
 
83
83
  describe('worktree creation naming convention (2q8j)', () => {
84
84
 
85
- it('creates worktree with xt/<name> branch when name is provided', () => {
86
- const today = new Date();
87
- const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`;
88
- const expectedName = `myproject-xt-claude-${dateStr}`;
89
- const expectedPath = path.join(siblingBase, expectedName);
85
+ it('creates worktree inside repo under .xtrm/worktrees/ with xt/<name> branch', () => {
86
+ // Worktree lands at <repoDir>/.xtrm/worktrees/myproject-xt-claude-mysession
87
+ const expectedPath = path.join(repoDir, '.xtrm', 'worktrees', 'myproject-xt-claude-mysession');
90
88
 
91
- // Pre-clean any stale worktree from previous runs
92
89
  if (fs.existsSync(expectedPath)) {
93
90
  removeWorktree(expectedPath, repoDir);
94
91
  }
95
92
 
96
- run(['claude', 'mysession'], {
97
- cwd: repoDir,
98
- env: { PATH: '/usr/bin:/bin' },
99
- });
93
+ run(['claude', 'mysession'], { cwd: repoDir, env: { PATH: '/usr/bin:/bin' } });
100
94
 
101
- if (fs.existsSync(expectedPath)) {
102
- const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
103
- cwd: expectedPath, encoding: 'utf8', stdio: 'pipe',
104
- });
105
- expect(branchResult.stdout.trim()).toBe('xt/mysession');
95
+ expect(fs.existsSync(expectedPath)).toBe(true);
106
96
 
107
- // Verify it's a sibling of repoDir, not nested inside it
108
- // Use repoDir + sep to avoid "myproject-xt-..." startsWith "myproject"
109
- expect(expectedPath.startsWith(repoDir + path.sep)).toBe(false);
110
- expect(path.dirname(expectedPath)).toBe(siblingBase);
97
+ const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
98
+ cwd: expectedPath, encoding: 'utf8', stdio: 'pipe',
99
+ });
100
+ expect(branchResult.stdout.trim()).toBe('xt/mysession');
111
101
 
112
- removeWorktree(expectedPath, repoDir);
113
- }
102
+ // Worktree is nested inside repoDir, not a sibling
103
+ expect(expectedPath.startsWith(repoDir + path.sep)).toBe(true);
104
+
105
+ removeWorktree(expectedPath, repoDir);
114
106
  });
115
107
 
116
108
  it('creates worktree with random xt/<slug> branch when no name provided', () => {
117
- const today = new Date();
118
- const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`;
119
- const expectedName = `myproject-xt-pi-${dateStr}`;
120
- const expectedPath = path.join(siblingBase, expectedName);
109
+ // Worktree lands at <repoDir>/.xtrm/worktrees/myproject-xt-pi-<slug>
110
+ const worktreesDir = path.join(repoDir, '.xtrm', 'worktrees');
121
111
 
122
- if (fs.existsSync(expectedPath)) {
123
- removeWorktree(expectedPath, repoDir);
124
- }
112
+ run(['pi'], { cwd: repoDir, env: { PATH: '/usr/bin:/bin' } });
125
113
 
126
- run(['pi'], {
127
- cwd: repoDir,
128
- env: { PATH: '/usr/bin:/bin' },
114
+ // Find the created worktree (slug is random, so glob the dir)
115
+ const entries = fs.existsSync(worktreesDir)
116
+ ? fs.readdirSync(worktreesDir).filter(e => e.startsWith('myproject-xt-pi-'))
117
+ : [];
118
+
119
+ expect(entries.length).toBeGreaterThan(0);
120
+
121
+ const wtPath = path.join(worktreesDir, entries[0]!);
122
+ const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
123
+ cwd: wtPath, encoding: 'utf8', stdio: 'pipe',
129
124
  });
125
+ expect(branchResult.stdout.trim()).toMatch(/^xt\/[a-z0-9]{4}$/);
130
126
 
131
- if (fs.existsSync(expectedPath)) {
132
- const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
133
- cwd: expectedPath, encoding: 'utf8', stdio: 'pipe',
134
- });
135
- expect(branchResult.stdout.trim()).toMatch(/^xt\/[a-z0-9]{4}$/);
136
- removeWorktree(expectedPath, repoDir);
137
- }
127
+ removeWorktree(wtPath, repoDir);
138
128
  });
139
129
  });