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
@@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process';
5
5
  import { homedir } from 'node:os';
6
6
  import { findRepoRoot } from '../utils/repo-root.js';
7
7
  import { t, sym } from '../utils/theme.js';
8
+ import { syncManagedPiExtensions } from '../utils/pi-extensions.js';
8
9
 
9
10
  const PI_AGENT_DIR = process.env.PI_AGENT_DIR || path.join(homedir(), '.pi', 'agent');
10
11
 
@@ -19,24 +20,6 @@ function isPiInstalled(): boolean {
19
20
  return r.status === 0;
20
21
  }
21
22
 
22
- /**
23
- * List extension directories (contain package.json) in a base directory.
24
- */
25
- async function listExtensionDirs(baseDir: string): Promise<string[]> {
26
- if (!await fs.pathExists(baseDir)) return [];
27
- const entries = await fs.readdir(baseDir, { withFileTypes: true });
28
- const extDirs: string[] = [];
29
- for (const entry of entries) {
30
- if (!entry.isDirectory()) continue;
31
- const extPath = path.join(baseDir, entry.name);
32
- const pkgPath = path.join(extPath, 'package.json');
33
- if (await fs.pathExists(pkgPath)) {
34
- extDirs.push(extPath);
35
- }
36
- }
37
- return extDirs;
38
- }
39
-
40
23
  /**
41
24
  * Non-interactive Pi install: copies extensions + installs npm packages.
42
25
  * Called automatically as part of `xtrm install`.
@@ -65,34 +48,17 @@ export async function runPiInstall(dryRun: boolean = false): Promise<void> {
65
48
  console.log(t.success(` ✓ pi ${v.stdout.trim()} already installed`));
66
49
  }
67
50
 
68
- // Copy extensions
51
+ // Sync managed extensions (Pi auto-discovers from ~/.pi/agent/extensions)
69
52
  const extensionsSrc = path.join(piConfigDir, 'extensions');
70
53
  const extensionsDst = path.join(PI_AGENT_DIR, 'extensions');
71
- if (await fs.pathExists(extensionsSrc)) {
72
- if (!dryRun) {
73
- await fs.ensureDir(PI_AGENT_DIR);
74
- await fs.copy(extensionsSrc, extensionsDst, { overwrite: true });
75
- }
76
- console.log(t.success(` ${sym.ok} extensions synced`));
77
-
78
- // Register each extension with pi install -l
79
- const extDirs = await listExtensionDirs(extensionsDst);
80
- if (extDirs.length > 0) {
81
- console.log(kleur.dim(` Registering ${extDirs.length} extensions...`));
82
- for (const extPath of extDirs) {
83
- const extName = path.basename(extPath);
84
- if (dryRun) {
85
- console.log(kleur.cyan(` [DRY RUN] pi install -l ~/.pi/agent/extensions/${extName}`));
86
- continue;
87
- }
88
- const r = spawnSync('pi', ['install', '-l', extPath], { stdio: 'pipe', encoding: 'utf8' });
89
- if (r.status === 0) {
90
- console.log(t.success(` ${sym.ok} ${extName} registered`));
91
- } else {
92
- console.log(kleur.yellow(` ⚠ ${extName} — registration failed`));
93
- }
94
- }
95
- }
54
+ const managedPackages = await syncManagedPiExtensions({
55
+ sourceDir: extensionsSrc,
56
+ targetDir: extensionsDst,
57
+ dryRun,
58
+ log: (message) => console.log(kleur.dim(message)),
59
+ });
60
+ if (managedPackages > 0) {
61
+ console.log(t.success(` ${sym.ok} extensions synced (${managedPackages} packages)`));
96
62
  }
97
63
 
98
64
  // Install npm packages from schema
@@ -3,8 +3,6 @@ import path from 'path';
3
3
  import fs from 'fs-extra';
4
4
  // @ts-ignore
5
5
  import Conf from 'conf';
6
- // @ts-ignore
7
- import prompts from 'prompts';
8
6
  import kleur from 'kleur';
9
7
  import type { SyncMode } from '../types/config.js';
10
8
 
@@ -68,73 +66,27 @@ export function resolveTargets(
68
66
 
69
67
  export async function getContext(options: GetContextOptions = {}): Promise<Context> {
70
68
  const { selector, createMissingDirs = true } = options;
71
- const choices = [];
72
69
  const candidates = getCandidatePaths();
73
70
  const directTargets = resolveTargets(selector, candidates);
74
71
 
75
- if (directTargets) {
76
- const activeConfig = getConfig();
77
- if (createMissingDirs) {
78
- for (const target of directTargets) {
79
- await fs.ensureDir(target);
80
- }
81
- }
82
-
83
- return {
84
- targets: directTargets,
85
- syncMode: activeConfig.get('syncMode') as SyncMode,
86
- config: activeConfig,
87
- };
88
- }
89
-
90
72
  const activeConfig = getConfig();
91
73
 
92
- for (const c of candidates) {
93
- const exists = await fs.pathExists(c.path);
94
- const icon = exists ? kleur.green('●') : kleur.gray('○');
95
- const desc = exists ? 'Found' : 'Not found (will create)';
96
-
97
- choices.push({
98
- title: `${icon} ${c.label} (${c.path})`,
99
- description: desc,
100
- value: c.path,
101
- selected: exists, // Pre-select existing environments
102
- });
103
- }
104
-
105
- const response = await prompts({
106
- type: 'multiselect',
107
- name: 'targets',
108
- message: 'Select target environment(s):',
109
- choices: choices,
110
- hint: '- Space to select. Return to submit',
111
- instructions: false,
112
- });
113
-
114
- if (response.targets === undefined) {
115
- console.log(kleur.gray('\nCancelled.'));
116
- process.exit(130);
117
- }
118
- if (response.targets.length === 0) {
119
- console.log(kleur.gray('No targets selected.'));
120
- process.exit(0);
121
- }
74
+ // Use explicitly specified targets, or default to all candidates
75
+ const selectedPaths = directTargets ?? candidates.map(c => c.path);
122
76
 
123
- // Ensure directories exist for selected targets
124
77
  if (createMissingDirs) {
125
- for (const target of response.targets) {
78
+ for (const target of selectedPaths) {
126
79
  await fs.ensureDir(target);
127
80
  }
128
81
  }
129
82
 
130
83
  return {
131
- targets: response.targets,
84
+ targets: selectedPaths,
132
85
  syncMode: activeConfig.get('syncMode') as SyncMode,
133
86
  config: activeConfig,
134
87
  };
135
88
 
136
89
  }
137
-
138
90
  export function resetContext(): void {
139
91
  getConfig().clear();
140
92
  console.log(kleur.yellow('Configuration cleared.'));
package/src/core/diff.ts CHANGED
@@ -2,7 +2,6 @@ import { join, normalize } from 'path';
2
2
  import fs from 'fs-extra';
3
3
  import { hashDirectory, getNewestMtime } from '../utils/hash.js';
4
4
  import type { ChangeSet } from '../types/config.js';
5
- import { getAdapter } from '../adapters/registry.js';
6
5
  import { detectAdapter } from '../adapters/registry.js';
7
6
 
8
7
  // Items to ignore from diff scanning (similar to .gitignore)
@@ -18,7 +17,6 @@ export class PruneModeReadError extends Error {
18
17
  export async function calculateDiff(repoRoot: string, systemRoot: string, pruneMode: boolean = false): Promise<ChangeSet> {
19
18
  const adapter = detectAdapter(systemRoot);
20
19
  const isClaude = adapter?.toolName === 'claude-code';
21
- const isQwen = adapter?.toolName === 'qwen';
22
20
  const normalizedRoot = normalize(systemRoot).replace(/\\/g, '/');
23
21
  const isAgentsSkills = normalizedRoot.includes('.agents/skills');
24
22
 
@@ -27,8 +25,6 @@ export async function calculateDiff(repoRoot: string, systemRoot: string, pruneM
27
25
  hooks: { missing: [], outdated: [], drifted: [], total: 0 },
28
26
  config: { missing: [], outdated: [], drifted: [], total: 0 },
29
27
  commands: { missing: [], outdated: [], drifted: [], total: 0 },
30
- 'qwen-commands': { missing: [], outdated: [], drifted: [], total: 0 },
31
- 'antigravity-workflows': { missing: [], outdated: [], drifted: [], total: 0 },
32
28
  };
33
29
 
34
30
  // Load installed file hashes from manifest for precise drift classification
@@ -61,20 +57,11 @@ export async function calculateDiff(repoRoot: string, systemRoot: string, pruneM
61
57
 
62
58
  // 1. Folders: Skills & Hooks & Commands
63
59
  const folders = ['skills', 'hooks'];
64
- if (isQwen) folders.push('qwen-commands');
65
- else if (!isClaude) folders.push('commands');
60
+ if (!isClaude) folders.push('commands');
66
61
 
67
62
  for (const category of folders) {
68
- let repoPath: string;
69
- let systemPath: string;
70
-
71
- if (category === 'qwen-commands') {
72
- repoPath = join(repoRoot, '.qwen', 'commands');
73
- systemPath = join(systemRoot, 'commands');
74
- } else {
75
- repoPath = join(repoRoot, category);
76
- systemPath = join(systemRoot, category);
77
- }
63
+ const repoPath = join(repoRoot, category);
64
+ const systemPath = join(systemRoot, category);
78
65
 
79
66
  if (!(await fs.pathExists(repoPath))) continue;
80
67
 
@@ -54,7 +54,6 @@ function getCandidatePaths(): Array<{ label: string; path: string }> {
54
54
  const home = os.homedir();
55
55
  return [
56
56
  { label: '~/.claude (hooks + skills)', path: path.join(home, '.claude') },
57
- { label: '.qwen', path: path.join(home, '.qwen') },
58
57
  { label: '~/.agents/skills', path: path.join(home, '.agents', 'skills') },
59
58
  ];
60
59
  }
package/src/index.ts CHANGED
@@ -16,9 +16,10 @@ import { createStatusCommand } from './commands/status.js';
16
16
  import { createResetCommand } from './commands/reset.js';
17
17
  import { createHelpCommand } from './commands/help.js';
18
18
  import { createCleanCommand } from './commands/clean.js';
19
- import { createFinishCommand } from './commands/finish.js';
20
19
  import { createEndCommand } from './commands/end.js';
21
20
  import { createWorktreeCommand } from './commands/worktree.js';
21
+ import { createDocsCommand } from './commands/docs.js';
22
+ import { createDebugCommand } from './commands/debug.js';
22
23
  import { printBanner } from './utils/banner.js';
23
24
 
24
25
  const program = new Command();
@@ -51,9 +52,10 @@ program
51
52
  program.addCommand(createStatusCommand());
52
53
  program.addCommand(createResetCommand());
53
54
  program.addCommand(createCleanCommand());
54
- program.addCommand(createFinishCommand());
55
55
  program.addCommand(createEndCommand());
56
56
  program.addCommand(createWorktreeCommand());
57
+ program.addCommand(createDocsCommand());
58
+ program.addCommand(createDebugCommand());
57
59
  program.addCommand(createHelpCommand());
58
60
 
59
61
  // Default action: show help
@@ -76,11 +78,12 @@ process.on('unhandledRejection', (reason) => {
76
78
  process.exit(1);
77
79
  });
78
80
 
79
- // Show startup banner unless --help or --version flag is present
81
+ // Show banner only for the install command (never for help/version output)
80
82
  const isHelpOrVersion = process.argv.some(a => a === '--help' || a === '-h' || a === '--version' || a === '-V');
83
+ const isInstallCommand = (process.argv[2] ?? '') === 'install';
81
84
 
82
85
  (async () => {
83
- if (!isHelpOrVersion) {
86
+ if (!isHelpOrVersion && isInstallCommand) {
84
87
  await printBanner(version);
85
88
  }
86
89
  program.parseAsync(process.argv);
@@ -23,8 +23,6 @@ export const ChangeSetSchema = z.object({
23
23
  hooks: ChangeSetCategorySchema,
24
24
  config: ChangeSetCategorySchema,
25
25
  commands: ChangeSetCategorySchema,
26
- 'qwen-commands': ChangeSetCategorySchema,
27
- 'antigravity-workflows': ChangeSetCategorySchema,
28
26
  });
29
27
  export type ChangeSet = z.infer<typeof ChangeSetSchema>;
30
28
 
@@ -32,9 +32,9 @@ export async function injectHookConfig(targetDir: string, repoRoot: string): Pro
32
32
  events: ['userPromptSubmit'],
33
33
  },
34
34
  {
35
- name: 'serena-workflow-reminder',
36
- path: path.join(targetDir, 'hooks', 'serena-workflow-reminder.py'),
37
- events: ['toolUse'],
35
+ name: 'using-xtrm-reminder',
36
+ path: path.join(targetDir, 'hooks', 'using-xtrm-reminder.mjs'),
37
+ events: ['sessionStart'],
38
38
  },
39
39
  ];
40
40
 
@@ -0,0 +1,41 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+
4
+ export interface SyncPiExtensionsOptions {
5
+ sourceDir: string;
6
+ targetDir: string;
7
+ dryRun?: boolean;
8
+ log?: (message: string) => void;
9
+ }
10
+
11
+ /**
12
+ * Sync managed extension packages into ~/.pi/agent/extensions.
13
+ *
14
+ * Pi auto-discovers extensions from this directory, so we intentionally do not
15
+ * run `pi install -l` for these managed packages (prevents double registration).
16
+ */
17
+ export async function syncManagedPiExtensions({
18
+ sourceDir,
19
+ targetDir,
20
+ dryRun = false,
21
+ log,
22
+ }: SyncPiExtensionsOptions): Promise<number> {
23
+ if (!await fs.pathExists(sourceDir)) return 0;
24
+
25
+ if (!dryRun) {
26
+ await fs.ensureDir(path.dirname(targetDir));
27
+ await fs.copy(sourceDir, targetDir, { overwrite: true });
28
+ }
29
+
30
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
31
+ const managedPackages = entries.filter((entry) => entry.isDirectory()).length;
32
+
33
+ if (log) {
34
+ if (dryRun) {
35
+ log(` [DRY RUN] sync extensions ${sourceDir} -> ${targetDir}`);
36
+ }
37
+ log(` Pi will auto-discover ${managedPackages} extension package(s) from ${targetDir}`);
38
+ }
39
+
40
+ return managedPackages;
41
+ }
@@ -1,8 +1,8 @@
1
1
  import kleur from 'kleur';
2
2
  import path from 'node:path';
3
- import fs from 'fs-extra';
4
- import { execSync, spawnSync } from 'node:child_process';
5
- import { findRepoRoot } from './repo-root.js';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
6
 
7
7
  export interface WorktreeSessionOptions {
8
8
  runtime: 'claude' | 'pi';
@@ -13,78 +13,114 @@ function randomSlug(len: number = 4): string {
13
13
  return Math.random().toString(36).slice(2, 2 + len);
14
14
  }
15
15
 
16
- function shortDate(): string {
17
- const d = new Date();
18
- return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
16
+ function gitRepoRoot(cwd: string): string | null {
17
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], {
18
+ cwd, stdio: 'pipe', encoding: 'utf8',
19
+ });
20
+ return r.status === 0 ? (r.stdout ?? '').trim() : null;
19
21
  }
20
22
 
21
23
  /**
22
24
  * Launch a Claude or Pi session in a sandboxed git worktree.
23
25
  *
24
- * Worktree path: sibling to CWD, named <cwd-basename>-xt-<runtime>-<shortdate>
26
+ * Worktree path: inside repo under .xtrm/worktrees/, named <cwd-basename>-xt-<runtime>-<slug>
25
27
  * Branch: xt/<name> if name provided, xt/<4-char-random> otherwise
26
- * Dolt bootstrap: redirect worktree to main's canonical beads db
28
+ * Beads: bd worktree create sets up canonical .beads/redirect to share the main db
29
+ */
30
+
31
+ /**
32
+ * Resolve the statusline.mjs path: prefer the plugin cache (stays in sync with
33
+ * plugin version), fall back to ~/.claude/hooks/statusline.mjs.
27
34
  */
35
+ function resolveStatuslineScript(): string | null {
36
+ const pluginsFile = path.join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
37
+ try {
38
+ const plugins = JSON.parse(readFileSync(pluginsFile, 'utf8'));
39
+ for (const [key, entries] of Object.entries(plugins?.plugins ?? {}) as [string, any[]][]) {
40
+ if (!key.startsWith('xtrm-tools@') || !entries?.length) continue;
41
+ const p = path.join(entries[0].installPath, 'hooks', 'statusline.mjs');
42
+ if (existsSync(p)) return p;
43
+ }
44
+ } catch { /* fall through */ }
45
+ // Fallback: ~/.claude/hooks/statusline.mjs
46
+ const fallback = path.join(homedir(), '.claude', 'hooks', 'statusline.mjs');
47
+ return existsSync(fallback) ? fallback : null;
48
+ }
49
+
28
50
  export async function launchWorktreeSession(opts: WorktreeSessionOptions): Promise<void> {
29
51
  const { runtime, name } = opts;
30
52
  const cwd = process.cwd();
31
- const repoRoot = await findRepoRoot();
32
- const cwdBasename = path.basename(cwd);
33
53
 
34
- // Resolve worktree path (sibling to cwd)
35
- const date = shortDate();
36
- const worktreeName = `${cwdBasename}-xt-${runtime}-${date}`;
37
- const worktreePath = path.join(path.dirname(cwd), worktreeName);
54
+ // Use git to find the user's actual repo root — not xtrm-tools' package root
55
+ const repoRoot = gitRepoRoot(cwd);
56
+ if (!repoRoot) {
57
+ console.error(kleur.red('\n ✗ Not inside a git repository\n'));
58
+ process.exit(1);
59
+ }
60
+
61
+ const cwdBasename = path.basename(repoRoot);
62
+
63
+ // Resolve slug — shared by both branch and worktree path so they're linked
64
+ const slug = name ?? randomSlug(4);
38
65
 
39
- // Resolve branch name
40
- const branchName = `xt/${name ?? randomSlug(4)}`;
66
+ // Worktree path: inside repo under .xtrm/worktrees/
67
+ const worktreeName = `${cwdBasename}-xt-${runtime}-${slug}`;
68
+ const worktreePath = path.join(repoRoot, '.xtrm', 'worktrees', worktreeName);
69
+
70
+ // Branch name
71
+ const branchName = `xt/${slug}`;
41
72
 
42
73
  console.log(kleur.bold(`\n Launching ${runtime} session`));
43
74
  console.log(kleur.dim(` worktree: ${worktreePath}`));
44
75
  console.log(kleur.dim(` branch: ${branchName}\n`));
45
76
 
46
- // Create worktree (create branch if it doesn't exist)
47
- const branchExists = spawnSync('git', ['rev-parse', '--verify', branchName], {
48
- cwd: repoRoot, stdio: 'pipe',
49
- }).status === 0;
50
-
51
- const worktreeArgs = branchExists
52
- ? ['worktree', 'add', worktreePath, branchName]
53
- : ['worktree', 'add', '-b', branchName, worktreePath];
54
-
55
- const worktreeResult = spawnSync('git', worktreeArgs, { cwd: repoRoot, stdio: 'inherit' });
56
- if (worktreeResult.status !== 0) {
57
- console.error(kleur.red(`\n ✗ Failed to create worktree at ${worktreePath}\n`));
58
- process.exit(1);
59
- }
77
+ // Use bd worktree create sets up git worktree + canonical .beads/redirect in one step.
78
+ // Falls back to plain git worktree add if bd is unavailable or the project has no .beads/.
79
+ const bdResult = spawnSync('bd', ['worktree', 'create', worktreePath, '--branch', branchName], {
80
+ cwd: repoRoot, stdio: 'inherit',
81
+ });
60
82
 
61
- // Dolt bootstrap: redirect worktree to main's canonical beads db
62
- const mainBeadsDir = path.join(repoRoot, '.beads');
63
- const worktreeBeadsDir = path.join(worktreePath, '.beads');
64
- const mainPortFile = path.join(mainBeadsDir, 'dolt-server.port');
65
-
66
- if (await fs.pathExists(mainBeadsDir)) {
67
- const worktreePortFile = path.join(worktreeBeadsDir, 'dolt-server.port');
68
-
69
- // Stop the auto-spawned isolated dolt server in the worktree
70
- spawnSync('bd', ['dolt', 'stop'], { cwd: worktreePath, stdio: 'pipe' });
71
-
72
- // Read main checkout's port and write to worktree
73
- if (await fs.pathExists(mainPortFile)) {
74
- const mainPort = (await fs.readFile(mainPortFile, 'utf8')).trim();
75
- await fs.ensureDir(worktreeBeadsDir);
76
- await fs.writeFile(worktreePortFile, mainPort, 'utf8');
77
- console.log(kleur.dim(` beads: redirected to main server (port ${mainPort})`));
78
- } else {
79
- console.log(kleur.dim(' beads: no port file found in main checkout, skipping redirect'));
83
+ if (bdResult.error || bdResult.status !== 0) {
84
+ // Fall back to plain git worktree add (bd not found or no .beads/ in project)
85
+ if (bdResult.status !== 0 && !bdResult.error) {
86
+ console.log(kleur.dim(' beads: no database found, creating worktree without redirect'));
87
+ }
88
+ const branchExists = spawnSync('git', ['rev-parse', '--verify', branchName], {
89
+ cwd: repoRoot, stdio: 'pipe',
90
+ }).status === 0;
91
+
92
+ const gitArgs = branchExists
93
+ ? ['worktree', 'add', worktreePath, branchName]
94
+ : ['worktree', 'add', '-b', branchName, worktreePath];
95
+
96
+ const gitResult = spawnSync('git', gitArgs, { cwd: repoRoot, stdio: 'inherit' });
97
+ if (gitResult.status !== 0) {
98
+ console.error(kleur.red(`\n ✗ Failed to create worktree at ${worktreePath}\n`));
99
+ process.exit(1);
80
100
  }
81
101
  }
82
102
 
83
103
  console.log(kleur.green(`\n ✓ Worktree ready — launching ${runtime}...\n`));
84
104
 
105
+ // Inject statusLine config for claude worktree sessions
106
+ if (runtime === 'claude') {
107
+ const statuslinePath = resolveStatuslineScript();
108
+ if (statuslinePath) {
109
+ const claudeDir = path.join(worktreePath, '.claude');
110
+ const localSettingsPath = path.join(claudeDir, 'settings.local.json');
111
+ try {
112
+ mkdirSync(claudeDir, { recursive: true });
113
+ writeFileSync(localSettingsPath, JSON.stringify({
114
+ statusLine: { type: 'command', command: `node ${statuslinePath}`, padding: 1 },
115
+ }, null, 2));
116
+ } catch { /* non-fatal — statusline is cosmetic */ }
117
+ }
118
+ }
119
+
85
120
  // Launch the runtime in the worktree
86
121
  const runtimeCmd = runtime === 'claude' ? 'claude' : 'pi';
87
- const launchResult = spawnSync(runtimeCmd, [], {
122
+ const runtimeArgs = runtime === 'claude' ? ['--dangerously-skip-permissions'] : [];
123
+ const launchResult = spawnSync(runtimeCmd, runtimeArgs, {
88
124
  cwd: worktreePath,
89
125
  stdio: 'inherit',
90
126
  });
@@ -0,0 +1,93 @@
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
+
6
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
7
+ isToolCallEventType: (name: string, event: any) => event?.toolName === name,
8
+ isBashToolResult: (event: any) => event?.toolName === "bash",
9
+ }));
10
+
11
+ vi.mock("../../../config/pi/extensions/core/lib", async () => {
12
+ const actual = await vi.importActual<any>("../../../config/pi/extensions/core/lib");
13
+ return {
14
+ ...actual,
15
+ SubprocessRunner: {
16
+ run: vi.fn(),
17
+ },
18
+ EventAdapter: {
19
+ isBeadsProject: vi.fn(() => true),
20
+ isMutatingFileTool: vi.fn((event: any) => event?.toolName === "write"),
21
+ parseBdCounts: vi.fn(() => ({ open: 2, inProgress: 0 })),
22
+ },
23
+ };
24
+ });
25
+
26
+ describe("Pi beads claim lifecycle", () => {
27
+ let harness: ExtensionHarness;
28
+
29
+ beforeEach(() => {
30
+ vi.resetAllMocks();
31
+ harness = new ExtensionHarness();
32
+ harness.pi.sendUserMessage = vi.fn();
33
+ });
34
+
35
+ it("clears stale claim and blocks edits until a fresh claim is made", async () => {
36
+ const calls: string[][] = [];
37
+ (SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
38
+ calls.push(args);
39
+ if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "xtrm-old\n", stderr: "" };
40
+ if (args[0] === "show") return { code: 0, stdout: JSON.stringify({ id: "xtrm-old", status: "closed" }), stderr: "" };
41
+ if (args[0] === "list") return { code: 0, stdout: "Total: 2 issues (2 open, 0 in progress)", stderr: "" };
42
+ if (args[0] === "kv" && args[1] === "clear") return { code: 0, stdout: "", stderr: "" };
43
+ return { code: 0, stdout: "", stderr: "" };
44
+ });
45
+
46
+ beadsExtension(harness.pi);
47
+
48
+ const result = await harness.emit("tool_call", {
49
+ toolName: "write",
50
+ input: { path: "src/main.ts" },
51
+ });
52
+
53
+ expect(calls.some((a) => a[0] === "kv" && a[1] === "clear" && `${a[2]}`.startsWith("claimed:"))).toBe(true);
54
+ expect(result?.block).toBe(true);
55
+ expect(result?.reason).toContain("No active claim");
56
+ });
57
+
58
+ it("does not block commit when claim is stale/closed", async () => {
59
+ (SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
60
+ if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "xtrm-old\n", stderr: "" };
61
+ if (args[0] === "show") return { code: 0, stdout: JSON.stringify({ id: "xtrm-old", status: "closed" }), stderr: "" };
62
+ if (args[0] === "kv" && args[1] === "clear") return { code: 0, stdout: "", stderr: "" };
63
+ return { code: 0, stdout: "", stderr: "" };
64
+ });
65
+
66
+ beadsExtension(harness.pi);
67
+
68
+ const result = await harness.emit("tool_call", {
69
+ toolName: "bash",
70
+ input: { command: "git commit -m 'test'" },
71
+ });
72
+
73
+ expect(result).toBeUndefined();
74
+ });
75
+
76
+ it("blocks commit when active claimed issue is still in progress", async () => {
77
+ (SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
78
+ if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "xtrm-live\n", stderr: "" };
79
+ if (args[0] === "show") return { code: 0, stdout: JSON.stringify({ id: "xtrm-live", status: "in_progress" }), stderr: "" };
80
+ return { code: 0, stdout: "", stderr: "" };
81
+ });
82
+
83
+ beadsExtension(harness.pi);
84
+
85
+ const result = await harness.emit("tool_call", {
86
+ toolName: "bash",
87
+ input: { command: "git commit -m 'test'" },
88
+ });
89
+
90
+ expect(result?.block).toBe(true);
91
+ expect(result?.reason).toContain("Active claim [xtrm-live]");
92
+ });
93
+ });