@lumoai/cli 1.15.0 → 1.18.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.
@@ -4,6 +4,7 @@ exports.sessionAttach = sessionAttach;
4
4
  const config_1 = require("../lib/config");
5
5
  const api_1 = require("../lib/api");
6
6
  const sanitize_1 = require("../lib/sanitize");
7
+ const line_prompt_1 = require("../lib/line-prompt");
7
8
  /**
8
9
  * `lumo session attach <identifier>` — bind the currently-running
9
10
  * Claude Code session to a task.
@@ -14,8 +15,15 @@ const sanitize_1 = require("../lib/sanitize");
14
15
  *
15
16
  * The binding lives entirely on the server (`Session.taskId`); subsequent
16
17
  * hooks read it back via the session row. The CLI keeps no local sentinel.
18
+ *
19
+ * Re-binding a session that's already attached to a *different* task no
20
+ * longer silently clobbers `Session.taskId` (LUM-266): the server returns
21
+ * the current binding and we confirm before overwriting —
22
+ * - `--force` skips the prompt and overwrites directly;
23
+ * - on a TTY we ask `已绑定 LUM-X,覆盖为 LUM-Y? [y/N]`;
24
+ * - off a TTY (the usual agent case) we refuse and point at `--force`.
17
25
  */
18
- async function sessionAttach(identifier) {
26
+ async function sessionAttach(identifier, options = {}) {
19
27
  if (!identifier) {
20
28
  console.error('Error: missing <identifier>. Usage: lumo session attach <LUM-42>');
21
29
  return 1;
@@ -33,48 +41,79 @@ async function sessionAttach(identifier) {
33
41
  }
34
42
  const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
35
43
  const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}/bind-task`;
36
- let res;
37
- try {
38
- res = await fetch(url, {
39
- method: 'POST',
40
- headers: {
41
- 'Content-Type': 'application/json',
42
- Authorization: `Bearer ${creds.token}`,
43
- },
44
- body: JSON.stringify({ taskIdentifier: identifier }),
45
- });
46
- }
47
- catch (err) {
48
- const msg = err instanceof Error ? err.message : String(err);
49
- console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
50
- return 1;
51
- }
52
- if (res.status === 401) {
53
- console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
54
- return 1;
55
- }
56
- if (res.status === 404) {
57
- let message = 'Not found';
44
+ const bind = async (force) => {
45
+ let res;
58
46
  try {
59
- const body = (await res.json());
60
- if (body.error)
61
- message = body.error;
47
+ res = await fetch(url, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ Authorization: `Bearer ${creds.token}`,
52
+ },
53
+ body: JSON.stringify({ taskIdentifier: identifier, force }),
54
+ });
62
55
  }
63
- catch {
64
- // fall through
56
+ catch (err) {
57
+ const msg = err instanceof Error ? err.message : String(err);
58
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
59
+ return { ok: false, code: 1 };
65
60
  }
66
- console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
67
- return 1;
61
+ if (res.status === 401) {
62
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
63
+ return { ok: false, code: 1 };
64
+ }
65
+ if (res.status === 404) {
66
+ let message = 'Not found';
67
+ try {
68
+ const data = (await res.json());
69
+ if (data.error)
70
+ message = data.error;
71
+ }
72
+ catch {
73
+ // fall through
74
+ }
75
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
76
+ return { ok: false, code: 1 };
77
+ }
78
+ if (!res.ok) {
79
+ console.error(`Error: bind-task failed (HTTP ${res.status})`);
80
+ return { ok: false, code: 1 };
81
+ }
82
+ return { ok: true, body: (await res.json()) };
83
+ };
84
+ // First attempt. `--force` overwrites unconditionally; otherwise the server
85
+ // may answer `already-bound` so we can confirm before clobbering.
86
+ const first = await bind(options.force === true);
87
+ if (!first.ok)
88
+ return first.code;
89
+ let body = first.body;
90
+ if (body.status === 'already-bound') {
91
+ // Reached only when not forced (force=true would have overwritten).
92
+ if (!process.stdin.isTTY) {
93
+ console.error(`Session already bound to ${body.currentTaskIdentifier} "${(0, sanitize_1.sanitizeField)(body.currentTaskTitle)}". ` +
94
+ `Not overwriting. Re-run with --force to switch to ${identifier} ` +
95
+ '(or run `lumo session detach` first).');
96
+ return 0;
97
+ }
98
+ const answer = await (0, line_prompt_1.promptLine)(`已绑定 ${body.currentTaskIdentifier},覆盖为 ${identifier}? [y/N] `);
99
+ if (!/^y(es)?$/i.test(answer)) {
100
+ console.log(`已取消,仍绑定 ${body.currentTaskIdentifier}。`);
101
+ return 0;
102
+ }
103
+ const second = await bind(true);
104
+ if (!second.ok)
105
+ return second.code;
106
+ body = second.body;
68
107
  }
69
- if (!res.ok) {
70
- console.error(`Error: bind-task failed (HTTP ${res.status})`);
108
+ if (body.status === 'already-bound') {
109
+ // Defensive: a forced bind should never report already-bound.
110
+ console.error(`Error: bind did not take effect (still bound to ${body.currentTaskIdentifier}).`);
71
111
  return 1;
72
112
  }
73
- const result = (await res.json());
74
- console.log(`Attached session ${sessionId} to ${result.taskIdentifier} "${(0, sanitize_1.sanitizeField)(result.taskTitle)}"`);
75
- console.log(`Re-tagged ${result.retaggedEventCount} previously-untagged event${result.retaggedEventCount === 1 ? '' : 's'} in this session.`);
76
- if (result.memorySection !== '') {
113
+ console.log(`Attached session ${sessionId} to ${body.taskIdentifier} "${(0, sanitize_1.sanitizeField)(body.taskTitle)}"`);
114
+ console.log(`Re-tagged ${body.retaggedEventCount} previously-untagged event${body.retaggedEventCount === 1 ? '' : 's'} in this session.`);
115
+ if (body.memorySection !== '') {
77
116
  console.log('');
78
- console.log((0, sanitize_1.sanitizeField)(result.memorySection));
117
+ console.log((0, sanitize_1.sanitizeField)(body.memorySection));
79
118
  }
80
119
  }
@@ -103,33 +103,57 @@ async function resolveScope(options) {
103
103
  process.stderr.write(`Unrecognized choice "${answer}". Aborting.\n`);
104
104
  return null;
105
105
  }
106
+ // Recursively list files under `dir` as paths relative to `base`.
107
+ function listSkillFiles(dir, base = dir) {
108
+ const out = [];
109
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
110
+ const full = path.join(dir, entry.name);
111
+ if (entry.isDirectory())
112
+ out.push(...listSkillFiles(full, base));
113
+ else if (entry.isFile())
114
+ out.push(path.relative(base, full));
115
+ }
116
+ return out;
117
+ }
118
+ function filesEqual(src, dst) {
119
+ if (!fs.existsSync(dst))
120
+ return false;
121
+ if (fs.statSync(src).size !== fs.statSync(dst).size)
122
+ return false;
123
+ return fs.readFileSync(src).equals(fs.readFileSync(dst));
124
+ }
106
125
  function installSkill(claudeDir, force) {
107
126
  const skillDir = path.join(claudeDir, 'skills', 'lumo');
108
- const skillDst = path.join(skillDir, 'SKILL.md');
109
- // Asset bundled in cli/assets/skill.md, shipped via package.json "files".
110
- // From dist/cli/src/commands/setup.js ../../../../assets/skill.md
111
- const skillSrc = path.resolve(__dirname, '../../../..', 'assets', 'skill.md');
112
- if (!fs.existsSync(skillSrc)) {
113
- throw new Error(`Bundled skill asset missing at ${skillSrc} — reinstall @lumoai/cli.`);
114
- }
115
- fs.mkdirSync(skillDir, { recursive: true });
116
- if (fs.existsSync(skillDst) && !force) {
117
- const srcStat = fs.statSync(skillSrc);
118
- const dstStat = fs.statSync(skillDst);
119
- if (srcStat.size === dstStat.size) {
120
- const a = fs.readFileSync(skillSrc, 'utf8');
121
- const b = fs.readFileSync(skillDst, 'utf8');
122
- if (a === b) {
123
- process.stdout.write(`✓ skill already up to date: ${skillDst}\n`);
124
- return;
125
- }
127
+ const skillDstMain = path.join(skillDir, 'SKILL.md');
128
+ // The skill is a directory (SKILL.md + references/*.md) bundled in
129
+ // cli/assets/skill/, shipped via package.json "files".
130
+ // From dist/cli/src/commands/setup.js ../../../../assets/skill
131
+ const skillSrcDir = path.resolve(__dirname, '../../../..', 'assets', 'skill');
132
+ if (!fs.existsSync(path.join(skillSrcDir, 'SKILL.md'))) {
133
+ throw new Error(`Bundled skill asset missing at ${skillSrcDir} — reinstall @lumoai/cli.`);
134
+ }
135
+ const relFiles = listSkillFiles(skillSrcDir);
136
+ // Non-force, install already present: only overwrite when something differs,
137
+ // and then only with --force.
138
+ if (fs.existsSync(skillDstMain) && !force) {
139
+ const allMatch = relFiles.every(rel => filesEqual(path.join(skillSrcDir, rel), path.join(skillDir, rel)));
140
+ if (allMatch) {
141
+ process.stdout.write(`✓ skill already up to date: ${skillDir}\n`);
142
+ return;
126
143
  }
127
- process.stdout.write(`⚠ ${skillDst} exists and differs from the bundled version.\n` +
144
+ process.stdout.write(`⚠ ${skillDir} exists and differs from the bundled version.\n` +
128
145
  ` Re-run with --force to overwrite.\n`);
129
146
  return;
130
147
  }
131
- fs.copyFileSync(skillSrc, skillDst);
132
- process.stdout.write(`✓ wrote skill: ${skillDst}\n`);
148
+ // Fresh install or --force: replace the managed references/ dir wholesale so
149
+ // stale reference files don't linger, then copy every bundled file.
150
+ fs.rmSync(path.join(skillDir, 'references'), { recursive: true, force: true });
151
+ for (const rel of relFiles) {
152
+ const dst = path.join(skillDir, rel);
153
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
154
+ fs.copyFileSync(path.join(skillSrcDir, rel), dst);
155
+ }
156
+ process.stdout.write(`✓ wrote skill (${relFiles.length} files): ${skillDir}\n`);
133
157
  }
134
158
  function mergeSettings(claudeDir, agentToken) {
135
159
  const settingsPath = path.join(claudeDir, 'settings.json');
@@ -172,7 +196,11 @@ function installGitHook(projectRoot) {
172
196
  printGitHookManualInstructions(coreHooksPath);
173
197
  return;
174
198
  }
175
- const hooksDirRaw = gitCapture(projectRoot, ['rev-parse', '--git-path', 'hooks']);
199
+ const hooksDirRaw = gitCapture(projectRoot, [
200
+ 'rev-parse',
201
+ '--git-path',
202
+ 'hooks',
203
+ ]);
176
204
  if (!hooksDirRaw) {
177
205
  process.stdout.write('⚠ could not resolve git hooks dir — skipped prepare-commit-msg hook.\n');
178
206
  return;
@@ -10,7 +10,15 @@ const sanitize_1 = require("../lib/sanitize");
10
10
  function fmtDate(iso) {
11
11
  return iso ? iso.slice(0, 10) : '-';
12
12
  }
13
- function formatSprintShow(s, progress, tasks) {
13
+ function blockerLine(label, items) {
14
+ if (items.length === 0)
15
+ return null;
16
+ const rendered = items
17
+ .map(i => `${i.identifier} ${(0, sanitize_1.sanitizeField)(i.title)}`)
18
+ .join(', ');
19
+ return ` ${label.padEnd(11)}${rendered}`;
20
+ }
21
+ function formatSprintShow(s, progress, tasks, risk, topBlockers) {
14
22
  const lines = [
15
23
  `Sprint: #${s.number} ${(0, sanitize_1.sanitizeField)(s.name)}`,
16
24
  `Status: ${s.status}`,
@@ -22,6 +30,27 @@ function formatSprintShow(s, progress, tasks) {
22
30
  ``,
23
31
  `Progress: ${progress.done} / ${progress.total}`,
24
32
  ];
33
+ if (risk) {
34
+ lines.push(`Health: ${risk.level.toUpperCase()}`);
35
+ for (const r of risk.reasons) {
36
+ lines.push(` - ${(0, sanitize_1.sanitizeField)(r.detail)}`);
37
+ }
38
+ }
39
+ if (topBlockers) {
40
+ const blockerLines = [
41
+ blockerLine('Overdue:', topBlockers.overdueTaskIds),
42
+ blockerLine('Stalled:', topBlockers.stalledTaskIds),
43
+ blockerLine('Agent fail:', topBlockers.failingAgentTaskIds),
44
+ topBlockers.staleOpenPrNumbers.length > 0
45
+ ? ` ${'Stale PRs:'.padEnd(11)}${topBlockers.staleOpenPrNumbers
46
+ .map(n => `#${n}`)
47
+ .join(', ')}`
48
+ : null,
49
+ ].filter((l) => l !== null);
50
+ if (blockerLines.length > 0) {
51
+ lines.push('Blockers:', ...blockerLines);
52
+ }
53
+ }
25
54
  if (tasks.length > 0) {
26
55
  lines.push('', (0, format_1.formatTaskListTable)(tasks));
27
56
  }
@@ -75,7 +104,7 @@ async function sprintShow(identifier, opts) {
75
104
  console.error(`Error: sprint tasks failed (HTTP ${tasksRes.status})`);
76
105
  return 1;
77
106
  }
78
- const { sprint, progress } = (await sprintRes.json());
107
+ const { sprint, progress, risk, topBlockers } = (await sprintRes.json());
79
108
  const { tasks } = (await tasksRes.json());
80
- process.stdout.write(formatSprintShow(sprint, progress, tasks) + '\n');
109
+ process.stdout.write(formatSprintShow(sprint, progress, tasks, risk, topBlockers) + '\n');
81
110
  }
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.worktreeAdd = worktreeAdd;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const child_process_1 = require("child_process");
40
+ const worktree_1 = require("../lib/worktree");
41
+ function printGuidance(dir, branch) {
42
+ console.log('');
43
+ console.log(`✓ Worktree ready: ${dir}`);
44
+ console.log(` branch: ${branch}`);
45
+ console.log(` node_modules: symlinked to main checkout`);
46
+ console.log('');
47
+ console.log('Next:');
48
+ console.log(` cd ${dir}`);
49
+ console.log('');
50
+ console.log('Gotchas baked into this worktree:');
51
+ console.log(' • prisma: the generated client is SHARED via the node_modules symlink.');
52
+ console.log(" Don't run `prisma generate` mid-work across parallel worktrees;");
53
+ console.log(' verify with jest, and do generate+tsc atomically once at the end.');
54
+ console.log(' • jest: always run it from INSIDE this worktree (cd first), or the');
55
+ console.log(' cli/package.json haste collision silently runs the wrong tests.');
56
+ }
57
+ async function worktreeAdd(rawId, slug, opts) {
58
+ const taskId = (0, worktree_1.normalizeTaskId)(rawId);
59
+ if (!taskId) {
60
+ console.error(`Error: "${rawId}" is not a task id (expected LUM-N or N)`);
61
+ return 1;
62
+ }
63
+ if (!(0, worktree_1.isMainCheckout)()) {
64
+ console.error('Error: run `lumo worktree add` from the main checkout, not inside a worktree');
65
+ return 1;
66
+ }
67
+ const root = (0, worktree_1.getRepoRoot)();
68
+ const branch = (0, worktree_1.deriveBranchName)(taskId, slug);
69
+ const dir = path.join(root, '.worktrees', (0, worktree_1.deriveWorktreeDirName)(taskId, slug));
70
+ if (fs.existsSync(dir)) {
71
+ console.error(`Error: ${dir} already exists — remove it first or pick a different slug`);
72
+ return 1;
73
+ }
74
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
75
+ const base = opts.base ?? 'origin/main';
76
+ if (!opts.noFetch && !opts.base) {
77
+ console.log('Fetching origin/main …');
78
+ if ((0, worktree_1.runGit)(['fetch', 'origin', 'main'], root).status !== 0) {
79
+ console.error(`Warning: git fetch failed; branching off the existing local ${base} (may be stale)`);
80
+ }
81
+ }
82
+ const branchExists = (0, worktree_1.runGit)(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], root)
83
+ .status === 0;
84
+ const addArgs = branchExists
85
+ ? ['worktree', 'add', dir, branch]
86
+ : ['worktree', 'add', dir, '-b', branch, base];
87
+ const added = (0, worktree_1.runGit)(addArgs, root);
88
+ if (added.status !== 0) {
89
+ console.error(`Error: ${(added.stderr || '').trim() || 'git worktree add failed'}`);
90
+ return 1;
91
+ }
92
+ const srcModules = path.join(root, 'node_modules');
93
+ if (fs.existsSync(srcModules)) {
94
+ fs.symlinkSync(srcModules, path.join(dir, 'node_modules'));
95
+ }
96
+ else {
97
+ console.log('Warning: main checkout has no node_modules; run `npm install` there, then symlink manually');
98
+ }
99
+ printGuidance(dir, branch);
100
+ if (opts.verify) {
101
+ console.log('Running baseline jest …');
102
+ const j = (0, child_process_1.spawnSync)('npx', ['jest'], { cwd: dir, stdio: 'inherit' });
103
+ if (j.status !== 0) {
104
+ console.error('Warning: baseline jest did not pass cleanly');
105
+ }
106
+ }
107
+ return 0;
108
+ }
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.worktreeList = worktreeList;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const worktree_1 = require("../lib/worktree");
40
+ const git_task_1 = require("../lib/git-task");
41
+ function nodeModulesLinked(dir) {
42
+ try {
43
+ return fs.lstatSync(path.join(dir, 'node_modules')).isSymbolicLink();
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ /** Pull a LUM-N from a worktree dir basename or its branch, else '—'. */
50
+ function taskIdOf(dirBase, branch) {
51
+ return ((0, git_task_1.matchTaskIdentifier)(dirBase) ??
52
+ (branch ? (0, git_task_1.matchTaskIdentifier)(branch) : null) ??
53
+ '—');
54
+ }
55
+ async function worktreeList() {
56
+ const mainRoot = path.dirname((0, worktree_1.getGitCommonDir)());
57
+ const worktreesDir = path.join(mainRoot, '.worktrees');
58
+ const entries = (0, worktree_1.parseWorktreePorcelain)((0, worktree_1.gitOutput)(['worktree', 'list', '--porcelain'], mainRoot)).filter(e => e.path.startsWith(worktreesDir + path.sep));
59
+ if (entries.length === 0) {
60
+ console.log('(no worktrees under .worktrees/)');
61
+ return 0;
62
+ }
63
+ for (const e of entries) {
64
+ const base = path.basename(e.path);
65
+ const id = taskIdOf(base, e.branch);
66
+ const dirty = (0, worktree_1.gitOutput)(['status', '--porcelain'], e.path).length > 0
67
+ ? 'dirty'
68
+ : 'clean';
69
+ const nm = nodeModulesLinked(e.path) ? 'linked' : 'MISSING';
70
+ console.log(`${id}\t${e.branch ?? '(detached)'}\t${dirty}\tnode_modules:${nm}\t${e.path}`);
71
+ }
72
+ return 0;
73
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.worktreeRm = worktreeRm;
4
+ const worktree_1 = require("../lib/worktree");
5
+ async function worktreeRm(rawId, opts) {
6
+ const taskId = (0, worktree_1.normalizeTaskId)(rawId);
7
+ if (!taskId) {
8
+ console.error(`Error: "${rawId}" is not a task id (expected LUM-N or N)`);
9
+ return 1;
10
+ }
11
+ if (!(0, worktree_1.isMainCheckout)()) {
12
+ console.error('Error: run `lumo worktree rm` from the main checkout, not inside a worktree');
13
+ return 1;
14
+ }
15
+ const root = (0, worktree_1.getRepoRoot)();
16
+ const entries = (0, worktree_1.parseWorktreePorcelain)((0, worktree_1.gitOutput)(['worktree', 'list', '--porcelain'], root));
17
+ const target = (0, worktree_1.findWorktreeByTaskId)(entries, taskId);
18
+ if (!target) {
19
+ console.error(`Error: no worktree found for ${taskId}`);
20
+ return 1;
21
+ }
22
+ if (!opts.yes) {
23
+ console.error(`Error: refusing to remove ${target.path} without --yes (destructive)`);
24
+ return 1;
25
+ }
26
+ const dirty = (0, worktree_1.gitOutput)(['status', '--porcelain'], target.path).length > 0;
27
+ if (dirty && !opts.force) {
28
+ console.error(`Error: ${target.path} has uncommitted changes — pass --force to remove anyway`);
29
+ return 1;
30
+ }
31
+ const removeArgs = dirty && opts.force
32
+ ? ['worktree', 'remove', '--force', target.path]
33
+ : ['worktree', 'remove', target.path];
34
+ const removed = (0, worktree_1.runGit)(removeArgs, root);
35
+ if (removed.status !== 0) {
36
+ console.error(`Error: ${(removed.stderr || '').trim() || 'git worktree remove failed'}`);
37
+ return 1;
38
+ }
39
+ console.log(`✓ Removed worktree ${target.path}`);
40
+ if (opts.deleteBranch && target.branch) {
41
+ const del = (0, worktree_1.runGit)(['branch', '-d', target.branch], root);
42
+ if (del.status !== 0) {
43
+ console.error(`Warning: could not delete branch ${target.branch}: ${(del.stderr || '').trim()}`);
44
+ }
45
+ else {
46
+ console.log(`✓ Deleted branch ${target.branch}`);
47
+ }
48
+ }
49
+ else if (target.branch) {
50
+ console.log(` branch ${target.branch} kept (use --delete-branch to remove)`);
51
+ }
52
+ return 0;
53
+ }
@@ -111,6 +111,9 @@ const doc_share_list_1 = require("./commands/doc-share-list");
111
111
  const doc_move_1 = require("./commands/doc-move");
112
112
  const update_1 = require("./commands/update");
113
113
  const setup_1 = require("./commands/setup");
114
+ const worktree_add_1 = require("./commands/worktree-add");
115
+ const worktree_rm_1 = require("./commands/worktree-rm");
116
+ const worktree_list_1 = require("./commands/worktree-list");
114
117
  const update_check_1 = require("./lib/update-check");
115
118
  const sanitize_1 = require("./lib/sanitize");
116
119
  // Resolve package.json relative to __dirname so this works regardless of how
@@ -186,7 +189,7 @@ program
186
189
  .description('Install the Lumo Claude Code skill and wire hook handlers into .claude/settings.json. Run via `npx @lumoai/cli setup` for first-time onboarding.')
187
190
  .option('--user', 'Install into ~/.claude (applies across all projects for this user)')
188
191
  .option('--project', 'Install into ./.claude (applies to the current project only)')
189
- .option('--force', 'Overwrite an existing SKILL.md when its contents differ from the bundled version')
192
+ .option('--force', 'Overwrite existing skill files (SKILL.md + references/) when they differ from the bundled version')
190
193
  .option('--agent <token>', 'Coding agent these hooks run under (claude-code, codex, cursor, gemini-cli, github-copilot, windsurf). Baked into every hook command. Defaults to claude-code.')
191
194
  .action(wrap(options => (0, setup_1.setup)(options)));
192
195
  program
@@ -199,8 +202,9 @@ const session = program
199
202
  .description('Manage per-terminal coding-session context');
200
203
  session
201
204
  .command('attach <identifier>')
202
- .description('Attach the currently-running Claude Code session (CLAUDE_CODE_SESSION_ID) to a task. Sets Session.taskId server-side and re-tags untagged hook events.')
203
- .action(wrap(identifier => (0, session_attach_1.sessionAttach)(identifier)));
205
+ .description('Attach the currently-running Claude Code session (CLAUDE_CODE_SESSION_ID) to a task. Sets Session.taskId server-side and re-tags untagged hook events. If the session is already bound to a different task, confirms before overwriting (use --force to skip).')
206
+ .option('--force', 'Overwrite an existing binding to a different task without confirmation (skips the [y/N] prompt).')
207
+ .action(wrap((identifier, options) => (0, session_attach_1.sessionAttach)(identifier, options)));
204
208
  session
205
209
  .command('status')
206
210
  .description('Show the task currently bound to this Claude Code session (or "no task" if none).')
@@ -654,6 +658,34 @@ task
654
658
  .option('--remove-tag <name>', 'Remove tag by name (repeatable). Unknown names are find-or-create, so prefer --remove-tag-id to avoid orphan rows.', collect, [])
655
659
  .option('--remove-tag-id <cuid>', 'Remove tag by id (repeatable). Unknown ids are a no-op.', collect, [])
656
660
  .action(wrap((identifier, options) => (0, task_update_1.taskUpdate)(identifier, options)));
661
+ const worktree = program
662
+ .command('worktree')
663
+ .description('Scaffold, remove, and list parallel git worktrees under .worktrees/ (node_modules symlink + prisma/jest gotcha guidance). Local dev tooling; run from the main checkout.');
664
+ worktree
665
+ .command('add <identifier> [slug]')
666
+ .description('Create a worktree at .worktrees/<LUM-N>[-slug] on branch lumo/<LUM-N>[-slug] off origin/main, symlink node_modules to the main checkout, and print prisma/jest guidance. Run from the main checkout.')
667
+ .option('--base <ref>', 'Branch off this ref instead of origin/main (skips fetch)')
668
+ .option('--no-fetch', 'Do not `git fetch origin main` before branching')
669
+ .option('--verify', 'Run `npx jest` in the new worktree to confirm a clean baseline')
670
+ .action(wrap((identifier, slug, options) => {
671
+ const o = options;
672
+ return (0, worktree_add_1.worktreeAdd)(identifier, slug, {
673
+ base: o.base,
674
+ noFetch: o.fetch === false,
675
+ verify: o.verify,
676
+ });
677
+ }));
678
+ worktree
679
+ .command('rm <identifier>')
680
+ .description('Remove the worktree for a task (git worktree remove). Requires --yes. Refuses a dirty worktree unless --force. Keeps the branch unless --delete-branch.')
681
+ .option('--yes', 'Confirm removal (required, no interactive prompt)')
682
+ .option('--force', 'Remove even with uncommitted changes')
683
+ .option('--delete-branch', 'Also delete the branch (git branch -d; refuses if unmerged)')
684
+ .action(wrap((identifier, options) => (0, worktree_rm_1.worktreeRm)(identifier, options)));
685
+ worktree
686
+ .command('list')
687
+ .description('List worktrees under .worktrees/: task id, branch, dirty state, and whether node_modules is symlinked.')
688
+ .action(wrap(() => (0, worktree_list_1.worktreeList)()));
657
689
  // Claude Code invokes `lumo hook <type>` per tool call, so these handlers
658
690
  // must never crash the caller. Two invariants keep us safe:
659
691
  // 1. `hookCommand` is stderr-silent and never throws (it catches internally