xtrm-cli 0.5.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 (93) hide show
  1. package/.gemini/settings.json +39 -0
  2. package/dist/index.cjs +57378 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2 -0
  5. package/extensions/beads.ts +109 -0
  6. package/extensions/core/adapter.ts +45 -0
  7. package/extensions/core/lib.ts +3 -0
  8. package/extensions/core/logger.ts +45 -0
  9. package/extensions/core/runner.ts +71 -0
  10. package/extensions/custom-footer.ts +160 -0
  11. package/extensions/main-guard-post-push.ts +44 -0
  12. package/extensions/main-guard.ts +126 -0
  13. package/extensions/minimal-mode.ts +201 -0
  14. package/extensions/quality-gates.ts +67 -0
  15. package/extensions/service-skills.ts +150 -0
  16. package/extensions/xtrm-loader.ts +89 -0
  17. package/hooks/gitnexus-impact-reminder.py +13 -0
  18. package/lib/atomic-config.js +236 -0
  19. package/lib/config-adapter.js +231 -0
  20. package/lib/config-injector.js +80 -0
  21. package/lib/context.js +73 -0
  22. package/lib/diff.js +142 -0
  23. package/lib/env-manager.js +160 -0
  24. package/lib/sync-mcp-cli.js +345 -0
  25. package/lib/sync.js +227 -0
  26. package/package.json +47 -0
  27. package/src/adapters/base.ts +29 -0
  28. package/src/adapters/claude.ts +38 -0
  29. package/src/adapters/registry.ts +21 -0
  30. package/src/commands/claude.ts +122 -0
  31. package/src/commands/clean.ts +371 -0
  32. package/src/commands/end.ts +239 -0
  33. package/src/commands/finish.ts +25 -0
  34. package/src/commands/help.ts +180 -0
  35. package/src/commands/init.ts +959 -0
  36. package/src/commands/install-pi.ts +276 -0
  37. package/src/commands/install-service-skills.ts +281 -0
  38. package/src/commands/install.ts +427 -0
  39. package/src/commands/pi-install.ts +119 -0
  40. package/src/commands/pi.ts +128 -0
  41. package/src/commands/reset.ts +12 -0
  42. package/src/commands/status.ts +170 -0
  43. package/src/commands/worktree.ts +193 -0
  44. package/src/core/context.ts +141 -0
  45. package/src/core/diff.ts +174 -0
  46. package/src/core/interactive-plan.ts +165 -0
  47. package/src/core/manifest.ts +26 -0
  48. package/src/core/preflight.ts +142 -0
  49. package/src/core/rollback.ts +32 -0
  50. package/src/core/session-state.ts +139 -0
  51. package/src/core/sync-executor.ts +427 -0
  52. package/src/core/xtrm-finish.ts +267 -0
  53. package/src/index.ts +87 -0
  54. package/src/tests/policy-parity.test.ts +204 -0
  55. package/src/tests/session-flow-parity.test.ts +118 -0
  56. package/src/tests/session-state.test.ts +124 -0
  57. package/src/tests/xtrm-finish.test.ts +148 -0
  58. package/src/types/config.ts +51 -0
  59. package/src/types/models.ts +52 -0
  60. package/src/utils/atomic-config.ts +467 -0
  61. package/src/utils/banner.ts +194 -0
  62. package/src/utils/config-adapter.ts +90 -0
  63. package/src/utils/config-injector.ts +81 -0
  64. package/src/utils/env-manager.ts +193 -0
  65. package/src/utils/hash.ts +42 -0
  66. package/src/utils/repo-root.ts +39 -0
  67. package/src/utils/sync-mcp-cli.ts +395 -0
  68. package/src/utils/theme.ts +37 -0
  69. package/src/utils/worktree-session.ts +93 -0
  70. package/test/atomic-config-prune.test.ts +101 -0
  71. package/test/atomic-config.test.ts +138 -0
  72. package/test/clean.test.ts +172 -0
  73. package/test/config-schema.test.ts +52 -0
  74. package/test/context.test.ts +33 -0
  75. package/test/end-worktree.test.ts +168 -0
  76. package/test/extensions/beads.test.ts +166 -0
  77. package/test/extensions/extension-harness.ts +85 -0
  78. package/test/extensions/main-guard.test.ts +77 -0
  79. package/test/extensions/minimal-mode.test.ts +107 -0
  80. package/test/extensions/quality-gates.test.ts +79 -0
  81. package/test/extensions/service-skills.test.ts +84 -0
  82. package/test/extensions/xtrm-loader.test.ts +53 -0
  83. package/test/hooks/quality-check-hooks.test.ts +45 -0
  84. package/test/hooks.test.ts +1075 -0
  85. package/test/install-pi.test.ts +185 -0
  86. package/test/install-project.test.ts +378 -0
  87. package/test/install-service-skills.test.ts +131 -0
  88. package/test/install-surface.test.ts +72 -0
  89. package/test/runtime-subcommands.test.ts +121 -0
  90. package/test/session-launcher.test.ts +139 -0
  91. package/tsconfig.json +22 -0
  92. package/tsup.config.ts +17 -0
  93. package/vitest.config.ts +10 -0
@@ -0,0 +1,371 @@
1
+ import { Command } from 'commander';
2
+ import kleur from 'kleur';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import { homedir } from 'os';
6
+ import { t, sym } from '../utils/theme.js';
7
+ import { findRepoRoot } from '../utils/repo-root.js';
8
+
9
+ // Canonical hooks (files in ~/.claude/hooks/)
10
+ const CANONICAL_HOOKS = new Set([
11
+ 'agent_context.py',
12
+ 'serena-workflow-reminder.py',
13
+ 'main-guard.mjs',
14
+ 'main-guard-post-push.mjs',
15
+ 'beads-gate-core.mjs',
16
+ 'beads-gate-utils.mjs',
17
+ 'beads-gate-messages.mjs',
18
+ 'beads-edit-gate.mjs',
19
+ 'beads-commit-gate.mjs',
20
+ 'beads-stop-gate.mjs',
21
+ 'beads-memory-gate.mjs',
22
+ 'beads-claim-sync.mjs',
23
+ 'beads-compact-save.mjs',
24
+ 'beads-compact-restore.mjs',
25
+ 'branch-state.mjs',
26
+ 'quality-check.cjs',
27
+ 'quality-check.py',
28
+ 'gitnexus', // directory
29
+ 'statusline-starship.sh',
30
+ 'README.md',
31
+ ]);
32
+
33
+ // Canonical skills (directories in ~/.agents/skills/)
34
+ const CANONICAL_SKILLS = new Set([
35
+ 'clean-code',
36
+ 'delegating',
37
+ 'docker-expert',
38
+ 'documenting',
39
+ 'find-skills',
40
+ 'gitnexus-debugging',
41
+ 'gitnexus-exploring',
42
+ 'gitnexus-impact-analysis',
43
+ 'gitnexus-refactoring',
44
+ 'hook-development',
45
+ 'obsidian-cli',
46
+ 'orchestrating-agents',
47
+ 'prompt-improving',
48
+ 'python-testing',
49
+ 'senior-backend',
50
+ 'senior-data-scientist',
51
+ 'senior-devops',
52
+ 'senior-security',
53
+ 'skill-creator',
54
+ 'using-serena-lsp',
55
+ 'using-TDD',
56
+ 'using-xtrm',
57
+ 'using-quality-gates',
58
+ 'using-service-skills',
59
+ 'updating-service-skills',
60
+ 'creating-service-skills',
61
+ 'scoping-service-skills',
62
+ ]);
63
+
64
+ // Directories/files to always ignore
65
+ const IGNORED_ITEMS = new Set([
66
+ '__pycache__',
67
+ '.DS_Store',
68
+ 'Thumbs.db',
69
+ '.gitkeep',
70
+ 'node_modules',
71
+ ]);
72
+
73
+ interface CleanResult {
74
+ hooksRemoved: string[];
75
+ skillsRemoved: string[];
76
+ cacheRemoved: string[];
77
+ }
78
+
79
+ async function cleanHooks(dryRun: boolean): Promise<{ removed: string[]; cache: string[] }> {
80
+ const hooksDir = path.join(homedir(), '.claude', 'hooks');
81
+ const removed: string[] = [];
82
+ const cache: string[] = [];
83
+
84
+ if (!await fs.pathExists(hooksDir)) {
85
+ return { removed, cache };
86
+ }
87
+
88
+ const entries = await fs.readdir(hooksDir);
89
+
90
+ for (const entry of entries) {
91
+ // Skip ignored items but track them for cache cleanup
92
+ if (IGNORED_ITEMS.has(entry)) {
93
+ if (!dryRun) {
94
+ const fullPath = path.join(hooksDir, entry);
95
+ await fs.remove(fullPath);
96
+ }
97
+ cache.push(entry);
98
+ continue;
99
+ }
100
+
101
+ // Check if it's canonical
102
+ if (CANONICAL_HOOKS.has(entry)) {
103
+ continue;
104
+ }
105
+
106
+ // Check if it's a file we should remove
107
+ const fullPath = path.join(hooksDir, entry);
108
+ const stat = await fs.stat(fullPath);
109
+
110
+ // Only remove files, not arbitrary directories (except cache dirs)
111
+ if (stat.isFile() || (stat.isDirectory() && IGNORED_ITEMS.has(entry))) {
112
+ if (!dryRun) {
113
+ await fs.remove(fullPath);
114
+ }
115
+ removed.push(entry);
116
+ }
117
+ }
118
+
119
+ return { removed, cache };
120
+ }
121
+
122
+ async function cleanSkills(dryRun: boolean): Promise<string[]> {
123
+ const skillsDir = path.join(homedir(), '.agents', 'skills');
124
+ const removed: string[] = [];
125
+
126
+ if (!await fs.pathExists(skillsDir)) {
127
+ return removed;
128
+ }
129
+
130
+ const entries = await fs.readdir(skillsDir);
131
+
132
+ for (const entry of entries) {
133
+ // Skip ignored items
134
+ if (IGNORED_ITEMS.has(entry)) {
135
+ continue;
136
+ }
137
+
138
+ // Skip README.txt
139
+ if (entry === 'README.txt') {
140
+ continue;
141
+ }
142
+
143
+ // Check if it's canonical
144
+ if (CANONICAL_SKILLS.has(entry)) {
145
+ continue;
146
+ }
147
+
148
+ // Remove non-canonical directory
149
+ const fullPath = path.join(skillsDir, entry);
150
+ const stat = await fs.stat(fullPath);
151
+
152
+ if (stat.isDirectory()) {
153
+ if (!dryRun) {
154
+ await fs.remove(fullPath);
155
+ }
156
+ removed.push(entry);
157
+ }
158
+ }
159
+
160
+ return removed;
161
+ }
162
+
163
+ async function cleanOrphanedHookEntries(dryRun: boolean, repoRoot: string | null): Promise<string[]> {
164
+ const settingsPath = path.join(homedir(), '.claude', 'settings.json');
165
+ const removed: string[] = [];
166
+
167
+ if (!await fs.pathExists(settingsPath)) {
168
+ return removed;
169
+ }
170
+
171
+ let settings: any = {};
172
+ try {
173
+ settings = await fs.readJson(settingsPath);
174
+ } catch {
175
+ return removed;
176
+ }
177
+
178
+ if (!settings.hooks || typeof settings.hooks !== 'object') {
179
+ return removed;
180
+ }
181
+
182
+ // Collect canonical script names from CANONICAL_HOOKS
183
+ const canonicalScripts = new Set<string>();
184
+ for (const hook of CANONICAL_HOOKS) {
185
+ if (hook.endsWith('.py') || hook.endsWith('.mjs') || hook.endsWith('.cjs') || hook.endsWith('.js')) {
186
+ canonicalScripts.add(hook);
187
+ }
188
+ }
189
+ canonicalScripts.add('gitnexus/gitnexus-hook.cjs');
190
+
191
+ // Build canonical wiring map from config/hooks.json: script -> Set<"event:::matcher|NONE">
192
+ // Used to detect canonical scripts wired to wrong events or with stale matchers.
193
+ const canonicalWiringKeys = new Map<string, Set<string>>();
194
+ if (repoRoot) {
195
+ const hooksJsonPath = path.join(repoRoot, 'config', 'hooks.json');
196
+ try {
197
+ if (await fs.pathExists(hooksJsonPath)) {
198
+ const hooksJson = await fs.readJson(hooksJsonPath);
199
+ for (const [event, entries] of Object.entries(hooksJson.hooks ?? {})) {
200
+ for (const entry of entries as any[]) {
201
+ const script: string = entry.script;
202
+ if (!script) continue;
203
+ const key = `${event}:::${entry.matcher ?? 'NONE'}`;
204
+ if (!canonicalWiringKeys.has(script)) canonicalWiringKeys.set(script, new Set());
205
+ canonicalWiringKeys.get(script)!.add(key);
206
+ }
207
+ }
208
+ }
209
+ } catch { /* ignore, fall back to script-only check */ }
210
+ }
211
+
212
+ // Check each hook entry
213
+ let modified = false;
214
+ for (const [event, wrappers] of Object.entries(settings.hooks)) {
215
+ if (!Array.isArray(wrappers)) continue;
216
+
217
+ const keptWrappers: any[] = [];
218
+ for (const wrapper of wrappers) {
219
+ const innerHooks = wrapper.hooks || [wrapper];
220
+ const keptInner: any[] = [];
221
+
222
+ for (const hook of innerHooks) {
223
+ const cmd = hook?.command || '';
224
+ const m = cmd.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
225
+ const script = m?.[1];
226
+
227
+ if (!script || canonicalScripts.has(script)) {
228
+ keptInner.push(hook);
229
+ } else {
230
+ removed.push(`${event}:${script}`);
231
+ modified = true;
232
+ }
233
+ }
234
+
235
+ if (keptInner.length > 0) {
236
+ // Validate canonical wiring: check that this (event, matcher) combo exists in canonical source
237
+ if (canonicalWiringKeys.size > 0) {
238
+ const firstCmd: string = keptInner[0]?.command || '';
239
+ const sm = firstCmd.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
240
+ const script = sm?.[1];
241
+
242
+ if (script && canonicalScripts.has(script)) {
243
+ const validKeys = canonicalWiringKeys.get(script);
244
+ const wiringKey = `${event}:::${(wrapper.matcher as string | undefined) ?? 'NONE'}`;
245
+ if (validKeys && !validKeys.has(wiringKey)) {
246
+ removed.push(`${event}:${script} (stale wiring)`);
247
+ modified = true;
248
+ continue; // drop this wrapper
249
+ }
250
+ }
251
+ }
252
+
253
+ if (wrapper.hooks) {
254
+ keptWrappers.push({ ...wrapper, hooks: keptInner });
255
+ } else if (keptInner.length === 1) {
256
+ keptWrappers.push(keptInner[0]);
257
+ }
258
+ }
259
+ }
260
+
261
+ if (keptWrappers.length > 0) {
262
+ settings.hooks[event] = keptWrappers;
263
+ } else {
264
+ delete settings.hooks[event];
265
+ modified = true;
266
+ }
267
+ }
268
+
269
+ if (modified && !dryRun) {
270
+ await fs.writeJson(settingsPath, settings, { spaces: 2 });
271
+ }
272
+
273
+ return removed;
274
+ }
275
+
276
+ export function createCleanCommand(): Command {
277
+ return new Command('clean')
278
+ .description('Remove orphaned hooks and skills not in the canonical repository')
279
+ .option('--dry-run', 'Preview what would be removed without making changes', false)
280
+ .option('--hooks-only', 'Only clean hooks, skip skills', false)
281
+ .option('--skills-only', 'Only clean skills, skip hooks', false)
282
+ .option('-y, --yes', 'Skip confirmation prompt', false)
283
+ .action(async (opts) => {
284
+ const { dryRun, hooksOnly, skillsOnly, yes } = opts;
285
+
286
+ console.log(t.bold('\n XTRM Clean — Remove Orphaned Components\n'));
287
+
288
+ if (dryRun) {
289
+ console.log(kleur.yellow(' DRY RUN — No changes will be made\n'));
290
+ }
291
+
292
+ const result: CleanResult = {
293
+ hooksRemoved: [],
294
+ skillsRemoved: [],
295
+ cacheRemoved: [],
296
+ };
297
+
298
+ // Clean hooks
299
+ if (!skillsOnly) {
300
+ console.log(kleur.bold(' Scanning ~/.claude/hooks/...'));
301
+ const { removed, cache } = await cleanHooks(dryRun);
302
+ result.hooksRemoved = removed;
303
+ result.cacheRemoved = cache;
304
+
305
+ if (removed.length > 0) {
306
+ for (const f of removed) {
307
+ console.log(kleur.red(` ✗ ${f}`));
308
+ }
309
+ } else {
310
+ console.log(kleur.dim(' ✓ No orphaned hooks found'));
311
+ }
312
+
313
+ if (cache.length > 0) {
314
+ console.log(kleur.dim(` ↳ Cleaned ${cache.length} cache directory(ies)`));
315
+ }
316
+
317
+ // Clean orphaned hook entries in settings.json
318
+ console.log(kleur.bold('\n Scanning settings.json for orphaned hook entries...'));
319
+ let repoRoot: string | null = null;
320
+ try { repoRoot = await findRepoRoot(); } catch { /* not in repo context */ }
321
+ const orphanedEntries = await cleanOrphanedHookEntries(dryRun, repoRoot);
322
+ if (orphanedEntries.length > 0) {
323
+ for (const entry of orphanedEntries) {
324
+ console.log(kleur.red(` ✗ ${entry}`));
325
+ }
326
+ } else {
327
+ console.log(kleur.dim(' ✓ No orphaned hook entries found'));
328
+ }
329
+ }
330
+
331
+ // Clean skills
332
+ if (!hooksOnly) {
333
+ console.log(kleur.bold('\n Scanning ~/.agents/skills/...'));
334
+ result.skillsRemoved = await cleanSkills(dryRun);
335
+
336
+ if (result.skillsRemoved.length > 0) {
337
+ for (const d of result.skillsRemoved) {
338
+ console.log(kleur.red(` ✗ ${d}/`));
339
+ }
340
+ } else {
341
+ console.log(kleur.dim(' ✓ No orphaned skills found'));
342
+ }
343
+ }
344
+
345
+ // Summary
346
+ const totalRemoved = result.hooksRemoved.length + result.skillsRemoved.length + result.cacheRemoved.length;
347
+
348
+ if (totalRemoved === 0) {
349
+ console.log(t.boldGreen('\n ✓ All components are canonical — nothing to clean\n'));
350
+ return;
351
+ }
352
+
353
+ console.log(kleur.bold('\n Summary:'));
354
+ if (result.hooksRemoved.length > 0) {
355
+ console.log(kleur.red(` ${result.hooksRemoved.length} orphaned hook(s)`));
356
+ }
357
+ if (result.skillsRemoved.length > 0) {
358
+ console.log(kleur.red(` ${result.skillsRemoved.length} orphaned skill(s)`));
359
+ }
360
+ if (result.cacheRemoved.length > 0) {
361
+ console.log(kleur.dim(` ${result.cacheRemoved.length} cache director(y/ies)`));
362
+ }
363
+
364
+ if (!dryRun) {
365
+ console.log(t.boldGreen('\n ✓ Cleanup complete\n'));
366
+ console.log(kleur.dim(' Run `xtrm install all -y` to reinstall canonical components\n'));
367
+ } else {
368
+ console.log(kleur.yellow('\n ℹ Dry run — run without --dry-run to apply changes\n'));
369
+ }
370
+ });
371
+ }
@@ -0,0 +1,239 @@
1
+ import { Command } from 'commander';
2
+ import kleur from 'kleur';
3
+ import prompts from 'prompts';
4
+ import { spawnSync, execSync } from 'node:child_process';
5
+ import { t } from '../utils/theme.js';
6
+
7
+ interface EndOptions {
8
+ draft: boolean;
9
+ keep: boolean;
10
+ yes: boolean;
11
+ }
12
+
13
+ function git(args: string[], cwd: string): { ok: boolean; out: string; err: string } {
14
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' });
15
+ return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
16
+ }
17
+
18
+ function bd(args: string[], cwd: string): { ok: boolean; out: string } {
19
+ const r = spawnSync('bd', args, { cwd, encoding: 'utf8', stdio: 'pipe' });
20
+ return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
21
+ }
22
+
23
+ /** Extract issue IDs from commit messages like "reason (jaggers-agent-tools-xxxx)" */
24
+ function extractIssueIds(commitLog: string): string[] {
25
+ const matches = commitLog.matchAll(/\(([a-z0-9]+-[a-z0-9]+-[a-z0-9]+)\)/g);
26
+ return [...new Set([...matches].map(m => m[1]))];
27
+ }
28
+
29
+ /** Generate PR title from issue data */
30
+ function buildPrTitle(issues: Array<{ id: string; reason: string; title: string }>): string {
31
+ if (issues.length === 0) return 'session changes';
32
+ if (issues.length === 1) return issues[0].reason || issues[0].title;
33
+ return `${issues[0].reason || issues[0].title} (+${issues.length - 1} more)`;
34
+ }
35
+
36
+ /** Generate PR body from issues, commit log, diff stat */
37
+ function buildPrBody(
38
+ issues: Array<{ id: string; title: string; description: string; reason: string }>,
39
+ commitLog: string,
40
+ diffStat: string,
41
+ branch: string,
42
+ ): string {
43
+ const lines: string[] = [];
44
+
45
+ lines.push('## What');
46
+ if (issues.length > 0) {
47
+ for (const issue of issues) {
48
+ lines.push(`- **${issue.id}**: ${issue.title}`);
49
+ if (issue.description) lines.push(` ${issue.description.split('\n')[0]}`);
50
+ }
51
+ } else {
52
+ lines.push(`Session branch: \`${branch}\``);
53
+ }
54
+
55
+ if (issues.some(i => i.reason)) {
56
+ lines.push('', '## Why');
57
+ for (const issue of issues) {
58
+ if (issue.reason) lines.push(`- ${issue.id}: ${issue.reason}`);
59
+ }
60
+ }
61
+
62
+ if (commitLog) {
63
+ lines.push('', '## Changes');
64
+ const commits = commitLog.split('\n').slice(0, 20);
65
+ lines.push(...commits.map(c => `- ${c}`));
66
+ if (commitLog.split('\n').length > 20) lines.push('- *(and more...)*');
67
+ }
68
+
69
+ if (diffStat) {
70
+ lines.push('', '## Files changed');
71
+ lines.push('```');
72
+ lines.push(diffStat);
73
+ lines.push('```');
74
+ }
75
+
76
+ if (issues.length > 0) {
77
+ lines.push('', `Closes: ${issues.map(i => i.id).join(' ')}`);
78
+ }
79
+
80
+ return lines.join('\n');
81
+ }
82
+
83
+ export function createEndCommand(): Command {
84
+ return new Command('end')
85
+ .description('Close session: rebase, push, open PR, link beads issues, clean up worktree')
86
+ .option('--draft', 'Open PR as draft', false)
87
+ .option('--keep', 'Keep worktree after PR creation (default: prompt)', false)
88
+ .option('-y, --yes', 'Skip confirmation prompts', false)
89
+ .action(async (opts: EndOptions) => {
90
+ const cwd = process.cwd();
91
+
92
+ // 1. Gate: must be in an xt worktree (branch starts with xt/)
93
+ const branchResult = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
94
+ const branch = branchResult.out;
95
+
96
+ if (!branch.startsWith('xt/')) {
97
+ console.error(kleur.red(
98
+ `\n ✗ Not in an xt worktree (current branch: ${branch})\n` +
99
+ ` xt end must be run from inside a worktree created by xt claude/pi\n`
100
+ ));
101
+ process.exit(1);
102
+ }
103
+
104
+ // 2. Gate: no uncommitted changes
105
+ const statusResult = git(['status', '--porcelain'], cwd);
106
+ if (statusResult.out.length > 0) {
107
+ console.error(kleur.red(
108
+ '\n ✗ Uncommitted changes detected. Commit or stash before running xt end.\n'
109
+ ));
110
+ console.error(kleur.dim(statusResult.out));
111
+ process.exit(1);
112
+ }
113
+
114
+ console.log(t.bold(`\n xt end — closing session on ${branch}\n`));
115
+
116
+ // 3. Collect closed issues from commit log
117
+ const logResult = git(['log', 'origin/main..HEAD', '--oneline'], cwd);
118
+ const issueIds = extractIssueIds(logResult.out);
119
+
120
+ const issues: Array<{ id: string; title: string; description: string; reason: string }> = [];
121
+ for (const id of issueIds) {
122
+ const showResult = bd(['show', id, '--json'], cwd);
123
+ if (showResult.ok) {
124
+ try {
125
+ const data = JSON.parse(showResult.out);
126
+ issues.push({
127
+ id,
128
+ title: data.title ?? id,
129
+ description: data.description ?? '',
130
+ reason: data.close_reason ?? '',
131
+ });
132
+ } catch {
133
+ issues.push({ id, title: id, description: '', reason: '' });
134
+ }
135
+ }
136
+ }
137
+
138
+ if (issues.length > 0) {
139
+ console.log(t.success(` ✓ Found ${issues.length} closed issue(s): ${issueIds.join(', ')}`));
140
+ } else {
141
+ console.log(kleur.dim(' ○ No beads issues found in commit log'));
142
+ }
143
+
144
+ // 4. Fetch to ensure origin/main is current
145
+ console.log(kleur.dim(' Fetching origin/main...'));
146
+ git(['fetch', 'origin', 'main'], cwd);
147
+
148
+ // 5. Rebase
149
+ console.log(kleur.dim(' Rebasing onto origin/main...'));
150
+ const rebaseResult = git(['rebase', 'origin/main'], cwd);
151
+ if (!rebaseResult.ok) {
152
+ const conflicts = git(['diff', '--name-only', '--diff-filter=U'], cwd).out;
153
+ console.error(kleur.red('\n ✗ Rebase conflicts detected:\n'));
154
+ if (conflicts) {
155
+ for (const f of conflicts.split('\n')) console.error(kleur.yellow(` ${f}`));
156
+ }
157
+ console.error(kleur.dim(
158
+ '\n Resolve conflicts, then:\n' +
159
+ ' git add <files> && git rebase --continue\n' +
160
+ ' Then re-run: xt end\n'
161
+ ));
162
+ process.exit(1);
163
+ }
164
+ console.log(t.success(' ✓ Rebased onto origin/main'));
165
+
166
+ // 6. Push (force-with-lease = safe after rebase)
167
+ console.log(kleur.dim(' Pushing branch...'));
168
+ const pushResult = git(['push', 'origin', branch, '--force-with-lease'], cwd);
169
+ if (!pushResult.ok) {
170
+ console.error(kleur.red(`\n ✗ Push failed:\n ${pushResult.err}\n`));
171
+ process.exit(1);
172
+ }
173
+ console.log(t.success(` ✓ Pushed ${branch}`));
174
+
175
+ // 7. Build PR content
176
+ const fullLog = git(['log', 'origin/main..HEAD', '--oneline'], cwd).out;
177
+ const diffStat = git(['diff', 'origin/main', '--stat'], cwd).out;
178
+ const prTitle = buildPrTitle(issues);
179
+ const prBody = buildPrBody(issues, fullLog, diffStat, branch);
180
+
181
+ // 8. Create PR
182
+ console.log(kleur.dim(' Creating PR...'));
183
+ const prArgs = ['pr', 'create', '--title', prTitle, '--body', prBody];
184
+ if (opts.draft) prArgs.push('--draft');
185
+
186
+ const prResult = spawnSync('gh', prArgs, { cwd, encoding: 'utf8', stdio: 'pipe' });
187
+ if (prResult.status !== 0) {
188
+ console.error(kleur.red(`\n ✗ PR creation failed:\n ${prResult.stderr?.trim()}\n`));
189
+ process.exit(1);
190
+ }
191
+ const prUrl = prResult.stdout.trim();
192
+ console.log(t.success(` ✓ PR created: ${prUrl}`));
193
+
194
+ // 9. Beads linkage: add PR URL to each closed issue's notes
195
+ for (const issue of issues) {
196
+ bd(['update', issue.id, '--notes', `PR: ${prUrl}`], cwd);
197
+ }
198
+ if (issues.length > 0) {
199
+ console.log(t.success(` ✓ Linked PR to ${issues.length} issue(s)`));
200
+ }
201
+
202
+ // 10. Worktree cleanup
203
+ if (!opts.keep) {
204
+ let doRemove = opts.yes;
205
+ if (!opts.yes) {
206
+ const { remove } = await prompts({
207
+ type: 'confirm',
208
+ name: 'remove',
209
+ message: `Remove local worktree at ${cwd}?`,
210
+ initial: false,
211
+ });
212
+ doRemove = remove;
213
+ }
214
+
215
+ if (doRemove) {
216
+ // Must run from outside the worktree
217
+ try {
218
+ const repoRoot = git(['rev-parse', '--show-toplevel'], cwd).out;
219
+ const removeResult = spawnSync(
220
+ 'git', ['worktree', 'remove', cwd, '--force'],
221
+ { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }
222
+ );
223
+ if (removeResult.status === 0) {
224
+ console.log(t.success(' ✓ Worktree removed'));
225
+ } else {
226
+ console.log(kleur.yellow(' ⚠ Could not remove worktree — remove manually:'));
227
+ console.log(kleur.dim(` git worktree remove ${cwd} --force`));
228
+ }
229
+ } catch {
230
+ console.log(kleur.yellow(' ⚠ Could not remove worktree automatically'));
231
+ }
232
+ }
233
+ }
234
+
235
+ console.log(t.boldGreen('\n ✓ Session closed\n'));
236
+ console.log(kleur.dim(` PR: ${prUrl}`));
237
+ console.log(kleur.dim(' Merge: review and merge when CI is green\n'));
238
+ });
239
+ }
@@ -0,0 +1,25 @@
1
+ import { Command } from 'commander';
2
+ import kleur from 'kleur';
3
+ import { runXtrmFinish } from '../core/xtrm-finish.js';
4
+
5
+ export function createFinishCommand(): Command {
6
+ return new Command('finish')
7
+ .description('Complete session closure lifecycle (phase1 + merge polling + cleanup)')
8
+ .option('--poll-interval-ms <ms>', 'Polling interval for PR state checks', (v) => Number(v), 5000)
9
+ .option('--timeout-ms <ms>', 'Maximum wait time before pending-cleanup', (v) => Number(v), 10 * 60 * 1000)
10
+ .action(async (opts) => {
11
+ const result = await runXtrmFinish({
12
+ cwd: process.cwd(),
13
+ pollIntervalMs: opts.pollIntervalMs,
14
+ timeoutMs: opts.timeoutMs,
15
+ });
16
+
17
+ if (result.ok) {
18
+ console.log(kleur.green(`\n✓ ${result.message}\n`));
19
+ return;
20
+ }
21
+
22
+ console.error(kleur.red(`\n✗ ${result.message}\n`));
23
+ process.exitCode = 1;
24
+ });
25
+ }