@lumoai/cli 1.9.0 → 1.10.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.
package/assets/skill.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lumo
3
- description: 'Use the Lumo CLI to load task context, manage session bindings, inspect projects and milestones, and create/update/list/show/comment on tasks from the terminal. Activate when: user mentions a Lumo task identifier (LUM-42, LUM-12, etc.), asks to load task background or context, wants to bind, check, or detach a Claude Code session''s task binding, is about to start development work on a specific task, wants to create a new task, list their tasks, view a task, comment on a task, list projects, list milestones, attach a task to a milestone, or update a task''s status/title/description/priority/assignee/milestone. Triggers on: "LUM-", "task context", "load context", "session start", "session attach", "session status", "session detach", "bind session", "unbind session", "which task", "what task am I on", "work on LUM", "create task", "new task", "add task", "file a task", "log a task", "list tasks", "my tasks", "show task", "view task", "comment on task", "leave a comment", "list projects", "what projects", "update task", "change task status", "rename task", "reassign task", "mark task as done", "milestone", "里程碑", "list milestones", "set milestone", "挂到 milestone", "attach milestone", "unbind milestone", "create milestone", "new milestone", "update milestone", "change milestone status", "delete milestone", "show milestone", "view milestone", "tasks in milestone", "milestone tasks", "新建里程碑", "更新里程碑", "删除里程碑", "查看里程碑", "auth login", "log in", "login", "auth logout", "log out", "logout", "sign out", "switch account", "switch identity", "whoami", "who am I", "current identity", "current user", "current workspace", "登录", "登出", "切换账号", "当前身份", "create doc", "new doc", "new document", "write doc", "写文档", "新建文档", "update doc", "edit doc", "修改文档", "更新文档", "list docs", "my docs", "我的文档", "show doc", "view doc", "查看文档", "delete doc", "删除文档", "bind doc", "attach doc to task", "把文档关联到任务", "文档绑定到任务", "unbind doc", "解绑文档", "personal doc", "workspace doc", "个人文档", "workspace 文档", "doc scope", "tag", "add tag", "remove tag", "标签", "添加标签", "移除标签", "doc tag", "task tag", "share doc", "doc share", "share document", "分享文档", "文档分享", "unshare doc", "remove share", "取消分享", "share list", "list doc shares", "who has access", "viewer", "editor", "manager", "shared with", "doc tree", "doc move", "move doc", "reparent doc", "文档树", "移动文档", "sprint", "start sprint", "close sprint", "add to sprint", "active sprints", "冲刺", "迭代", "开始冲刺", "关闭冲刺", "create sprint", "new sprint", "list sprints", "show sprint", "update sprint", "delete sprint", "sprint summary", "sprint retro", "把任务挂到冲刺", "冲刺里有什么", "lumo update", "update cli", "upgrade lumo", "update lumo", "upgrade cli", "升级 lumo", "更新 cli", "new lumo version", "是否有新版本", "lumo setup", "install lumo skill", "install lumo hooks", "wire up lumo", "set up lumo", "onboard lumo", "npx @lumoai/cli", "安装 lumo", "配置 lumo", "lumo 初始化", "task artifact", "artifact add", "artifact list", "artifact show", "artifact rm", "artifact delete", "artifact update", "update artifact", "edit artifact", "change artifact kind", "change artifact source", "remove artifact", "delete artifact", "spec artifact", "record spec", "attach spec", "attach plan", "记录 spec", "挂 spec", "查看 artifact", "编辑 artifact", "修改 artifact", "删除 artifact", figma, attach figma, figma link, 关联 figma, 设计稿, figma design, "memory", "记忆", "remember", "record a memory", "记一条", "promote memory", "promote to project", "沉淀", "task memory", "project memory", "lumo memory", "retrieval", "取全文", "load full content", "拉全文", "task slack show", "看 thread", "看 slack thread", "show slack thread", "slack 全文", "task web show", "show web link body", "web 正文", "抓网页正文", "task figma context", "figma metadata", "figma 元数据", "figma 设计上下文", "task comments list", "list comments", "看评论", "评论流", "task pr show", "查看 PR", "show pr", "PR 详情", "pr metadata", "import google doc", "sync google doc", "google drive", "doc import-gdoc", "doc sync", "导入 google 文档", "同步 google 文档", "session wrap", "wrap up session", "收尾", "post progress", "把进度发出去", "progress comment", "进度评论".'
3
+ description: 'Use the Lumo CLI to load task context, manage session bindings, inspect projects and milestones, and create/update/list/show/comment on tasks from the terminal. Activate when: user mentions a Lumo task identifier (LUM-42, LUM-12, etc.), asks to load task background or context, wants to bind, check, or detach a Claude Code session''s task binding, is about to start development work on a specific task, wants to create a new task, list their tasks, view a task, comment on a task, list projects, list milestones, attach a task to a milestone, or update a task''s status/title/description/priority/assignee/milestone. Triggers on: "LUM-", "task context", "load context", "session start", "session attach", "session status", "session detach", "bind session", "unbind session", "which task", "what task am I on", "work on LUM", "create task", "new task", "add task", "file a task", "log a task", "list tasks", "my tasks", "show task", "view task", "comment on task", "leave a comment", "list projects", "what projects", "update task", "change task status", "rename task", "reassign task", "mark task as done", "milestone", "里程碑", "list milestones", "set milestone", "挂到 milestone", "attach milestone", "unbind milestone", "create milestone", "new milestone", "update milestone", "change milestone status", "delete milestone", "show milestone", "view milestone", "tasks in milestone", "milestone tasks", "新建里程碑", "更新里程碑", "删除里程碑", "查看里程碑", "milestone summary", "milestone retro", "summarize milestone", "里程碑总结", "里程碑复盘", "milestone add", "milestone remove", "add tasks to milestone", "remove tasks from milestone", "batch milestone", "bulk milestone", "挂任务到里程碑", "批量挂里程碑", "从里程碑移除任务", "auth login", "log in", "login", "auth logout", "log out", "logout", "sign out", "switch account", "switch identity", "whoami", "who am I", "current identity", "current user", "current workspace", "登录", "登出", "切换账号", "当前身份", "create doc", "new doc", "new document", "write doc", "写文档", "新建文档", "update doc", "edit doc", "修改文档", "更新文档", "list docs", "my docs", "我的文档", "show doc", "view doc", "查看文档", "delete doc", "删除文档", "bind doc", "attach doc to task", "把文档关联到任务", "文档绑定到任务", "unbind doc", "解绑文档", "personal doc", "workspace doc", "个人文档", "workspace 文档", "doc scope", "tag", "add tag", "remove tag", "标签", "添加标签", "移除标签", "doc tag", "task tag", "share doc", "doc share", "share document", "分享文档", "文档分享", "unshare doc", "remove share", "取消分享", "share list", "list doc shares", "who has access", "viewer", "editor", "manager", "shared with", "doc tree", "doc move", "move doc", "reparent doc", "文档树", "移动文档", "sprint", "start sprint", "close sprint", "add to sprint", "active sprints", "冲刺", "迭代", "开始冲刺", "关闭冲刺", "create sprint", "new sprint", "list sprints", "show sprint", "update sprint", "delete sprint", "sprint summary", "sprint retro", "把任务挂到冲刺", "冲刺里有什么", "lumo update", "update cli", "upgrade lumo", "update lumo", "upgrade cli", "升级 lumo", "更新 cli", "new lumo version", "是否有新版本", "lumo setup", "install lumo skill", "install lumo hooks", "wire up lumo", "set up lumo", "onboard lumo", "npx @lumoai/cli", "安装 lumo", "配置 lumo", "lumo 初始化", "task artifact", "artifact add", "artifact list", "artifact show", "artifact rm", "artifact delete", "artifact update", "update artifact", "edit artifact", "change artifact kind", "change artifact source", "remove artifact", "delete artifact", "spec artifact", "record spec", "attach spec", "attach plan", "记录 spec", "挂 spec", "查看 artifact", "编辑 artifact", "修改 artifact", "删除 artifact", figma, attach figma, figma link, 关联 figma, 设计稿, figma design, "memory", "记忆", "remember", "record a memory", "记一条", "promote memory", "promote to project", "沉淀", "task memory", "project memory", "lumo memory", "retrieval", "取全文", "load full content", "拉全文", "task slack show", "看 thread", "看 slack thread", "show slack thread", "slack 全文", "task web show", "show web link body", "web 正文", "抓网页正文", "task figma context", "figma metadata", "figma 元数据", "figma 设计上下文", "task comments list", "list comments", "看评论", "评论流", "task pr show", "查看 PR", "show pr", "PR 详情", "pr metadata", "import google doc", "sync google doc", "google drive", "doc import-gdoc", "doc sync", "导入 google 文档", "同步 google 文档", "session wrap", "wrap up session", "收尾", "post progress", "把进度发出去", "progress comment", "进度评论".'
4
4
  ---
5
5
 
6
6
  ## Prerequisites
@@ -21,6 +21,16 @@ which lumo && lumo whoami
21
21
 
22
22
  Bootstraps Lumo into a coding-agent installation. Copies the bundled `SKILL.md` into `<scope>/.claude/skills/lumo/` and idempotently merges 25 hook entries into `<scope>/.claude/settings.json`. Existing user permissions and non-Lumo hook entries are preserved.
23
23
 
24
+ On `--project` scope, setup also installs a `prepare-commit-msg` git hook that
25
+ auto-appends the branch's task ID (`[LUM-N]`, parsed from a `lumo/LUM-N-...`
26
+ branch name) to any commit subject that lacks it. The hook is pure sh + git
27
+ (no network, no `lumo` call) and is merged idempotently between
28
+ `# >>> lumo prepare-commit-msg >>>` / `# <<< lumo <<<` markers, preserving any
29
+ existing hook content. When `core.hooksPath` is set (husky or a custom hooks
30
+ dir), setup does **not** write the file — it prints the block plus instructions
31
+ to add it manually (e.g. to `.husky/prepare-commit-msg`). `--user` scope does
32
+ not touch git hooks.
33
+
24
34
  `--agent <token>` records which coding agent these hooks run under and is **baked into every hook command** (`lumo hook <slug> --agent <token>`). Each hook then sends the agent to the server, where it's stored on the Session and inherited by auto-sedimented memories — so a memory is attributed to the agent that produced it instead of the default. Valid tokens: `claude-code | codex | cursor | gemini-cli | github-copilot | windsurf` (case-insensitive; `gemini` and `copilot` are accepted aliases). **Defaults to `claude-code`.** Re-running setup with a different `--agent` rewrites the token in place (no duplicate hook entries), and a legacy flagless entry is upgraded on the next run.
25
35
 
26
36
  ```bash
@@ -333,7 +343,7 @@ Task LUM-48 has no sprint binding # noop (already unbound)
333
343
 
334
344
  The CLI does **not** currently update due date or parent task. Those need to be edited in the web UI.
335
345
 
336
- Milestone updates (`--milestone`) and sprint binding (`--sprint`) both work. Full milestone CRUD is available via `lumo milestone create / show / update / delete` (see below). Full sprint CRUD is available via `lumo sprint create / show / update / delete / start / close / add / remove` (see below).
346
+ Milestone updates (`--milestone`) and sprint binding (`--sprint`) both work. Full milestone CRUD is available via `lumo milestone create / show / update / delete`, and tasks can be bound/unbound in bulk via `lumo milestone add / remove <identifier> <task...>` (see below). Full sprint CRUD is available via `lumo sprint create / show / update / delete / start / close / add / remove` (see below).
337
347
 
338
348
  ### `lumo task list [flags]` — list tasks assigned to you
339
349
 
@@ -585,6 +595,67 @@ Requires `--yes`. No interactive prompt — CLI is agent-friendly. Tasks under t
585
595
  lumo milestone delete "Q3 Launch" --yes
586
596
  ```
587
597
 
598
+ ### `lumo milestone add <identifier> <task...>` — bind tasks to a milestone (batch)
599
+
600
+ Binds **one or more** tasks to a milestone in a single call — the batch counterpart of `task update --milestone <ref>` (which only takes one task at a time). `<identifier>` accepts a milestone name or UUID; each `<task>` accepts `LUM-N` or a task UUID. `--project <ref>` is required when the identifier is a name and the workspace has >1 project.
601
+
602
+ Task refs are deduped (case-insensitive, order preserved). Each task is PATCHed independently — **partial failures do not roll back**: a task that fails (e.g. not found, or its project has no milestone of that name) is reported on its own line while the rest still bind. Exit code is non-zero if **any** task failed.
603
+
604
+ ```bash
605
+ lumo milestone add "Q3 Launch" LUM-1 LUM-2 LUM-3
606
+ lumo milestone add 11111111-2222-3333-4444-555555555555 LUM-1 LUM-2
607
+ ```
608
+
609
+ Output — a tally header (zero categories omitted) plus one line per task:
610
+
611
+ ```
612
+ Q3 Launch: 2 added, 1 failed
613
+ ✓ LUM-1
614
+ ✓ LUM-2
615
+ ✗ LUM-3 no milestone matches "Q3 Launch" in this project. Try `lumo milestone list`.
616
+ ```
617
+
618
+ ### `lumo milestone remove <identifier> <task...>` — unbind tasks from a milestone (batch)
619
+
620
+ Unbinds **one or more** tasks from a milestone in one call. A task that is **not currently in this milestone is skipped** (idempotent) — it is never reassigned away from a different milestone. `<identifier>` accepts a name or UUID; each `<task>` accepts `LUM-N` or a task UUID. `--project <ref>` is required when the identifier is a name and the workspace has >1 project.
621
+
622
+ ```bash
623
+ lumo milestone remove "Q3 Launch" LUM-1 LUM-5
624
+ ```
625
+
626
+ Output — `✓` removed, `-` skipped (not in milestone), `✗` failed:
627
+
628
+ ```
629
+ Q3 Launch: 1 removed, 1 skipped
630
+ ✓ LUM-1
631
+ - LUM-5 not in this milestone
632
+ ```
633
+
634
+ ### When to suggest `milestone add` / `milestone remove`
635
+
636
+ - The user wants to attach/detach **several** tasks to a milestone at once ("挂这几个任务到 Q3", "把 LUM-1 LUM-2 LUM-3 都放进里程碑", "remove these from the milestone"). For a single task, `task update --milestone <ref>` (or `--milestone ""` to clear) is equally fine.
637
+ - `remove` only clears the binding for tasks actually in the named milestone, so it's safe to pass a broad list — anything not in it is reported as skipped, not clobbered.
638
+ - For one-at-a-time sprint binding see `lumo sprint add / remove` (sprint batch is not yet supported).
639
+
640
+ ### `lumo milestone summary <identifier> [--retry]` — fetch AI-generated milestone retro
641
+
642
+ Prints the AI-generated retrospective summary for a milestone (mirrors `sprint summary`). `<identifier>` accepts a milestone name or UUID; `--project <ref>` is required when the identifier is a name and the workspace has more than one project. When no summary exists yet the command prints `(no summary generated yet)`.
643
+
644
+ A summary is generated automatically when a milestone transitions to `COMPLETED` (e.g. via `lumo milestone update <id> --status completed`). The generated report has sections `## Summary`, `## Delivered`, `## Outstanding` plus a one-line `tldr`. Use `--retry` to queue regeneration (e.g. after a failed generation) before fetching — regeneration is async, so the printed result may still be the previous summary or `(no summary generated yet)`.
645
+
646
+ | Flag | Type | Notes |
647
+ | ----------------- | ------- | ---------------------------------------------------------------- |
648
+ | `--project <ref>` | string | Project name or slug. Required when identifier is a name and the workspace has >1 project. |
649
+ | `--retry` | boolean | Queue a regeneration (async, server returns 202) before fetching. Only valid on a COMPLETED milestone. |
650
+
651
+ ```bash
652
+ lumo milestone summary "Q3 Launch"
653
+ lumo milestone summary "Q3 Launch" --retry
654
+ lumo milestone summary 11111111-2222-3333-4444-555555555555
655
+ ```
656
+
657
+ When to suggest: user asks "summarize the milestone", "milestone retro", "give me a summary of the Q3 milestone", "里程碑总结", "里程碑复盘".
658
+
588
659
  ## Document Management
589
660
 
590
661
  ### `lumo doc create [title] [flags]` — create a new document
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.milestoneAdd = milestoneAdd;
4
+ const config_1 = require("../lib/config");
5
+ const api_1 = require("../lib/api");
6
+ const resolve_1 = require("../lib/resolve");
7
+ const milestone_batch_1 = require("../lib/milestone-batch");
8
+ /**
9
+ * Look up the milestone's canonical name. `resolveMilestoneId` already
10
+ * returns the name when the identifier is a name; for a UUID it returns an
11
+ * empty name, so we GET the milestone to fill it in.
12
+ */
13
+ async function resolveMilestoneName(base, token, id, knownName) {
14
+ if (knownName)
15
+ return knownName;
16
+ const res = await fetch(`${base}/api/milestones/${id}`, {
17
+ headers: { Authorization: `Bearer ${token}` },
18
+ });
19
+ if (!res.ok) {
20
+ throw new Error(`milestone ${id} not found (HTTP ${res.status})`);
21
+ }
22
+ const { milestone } = (await res.json());
23
+ return milestone.name;
24
+ }
25
+ async function milestoneAdd(identifier, tasks, opts) {
26
+ const refs = (0, milestone_batch_1.dedupeTaskRefs)(tasks);
27
+ if (refs.length === 0) {
28
+ console.error('Error: provide at least one task (e.g. `LUM-1 LUM-2`).');
29
+ return 1;
30
+ }
31
+ const creds = (0, config_1.readCredentials)();
32
+ if (!creds) {
33
+ console.error('Error: not logged in. Run `lumo auth login` first.');
34
+ return 1;
35
+ }
36
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
37
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
38
+ let milestoneName;
39
+ try {
40
+ const resolved = await (0, resolve_1.resolveMilestoneId)(base, creds.token, identifier, opts.project);
41
+ milestoneName = await resolveMilestoneName(base, creds.token, resolved.id, resolved.name);
42
+ }
43
+ catch (err) {
44
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
45
+ return 1;
46
+ }
47
+ const outcomes = [];
48
+ for (const ref of refs) {
49
+ try {
50
+ const res = await fetch(`${base}/api/tasks/by-identifier/${encodeURIComponent(ref)}`, {
51
+ method: 'PATCH',
52
+ headers: {
53
+ Authorization: `Bearer ${creds.token}`,
54
+ 'Content-Type': 'application/json',
55
+ },
56
+ body: JSON.stringify({ milestoneRef: milestoneName }),
57
+ });
58
+ if (res.ok) {
59
+ outcomes.push({ task: ref, status: 'ok' });
60
+ }
61
+ else {
62
+ let msg = `add failed (HTTP ${res.status})`;
63
+ try {
64
+ const body = (await res.json());
65
+ if (body.error)
66
+ msg = body.error;
67
+ }
68
+ catch {
69
+ // non-JSON body; keep the status-only message
70
+ }
71
+ outcomes.push({ task: ref, status: 'fail', detail: msg });
72
+ }
73
+ }
74
+ catch (err) {
75
+ const msg = err instanceof Error ? err.message : String(err);
76
+ outcomes.push({ task: ref, status: 'fail', detail: msg });
77
+ }
78
+ }
79
+ process.stdout.write((0, milestone_batch_1.formatBatchSummary)({ milestoneName, verb: 'added', outcomes }) + '\n');
80
+ return outcomes.some(o => o.status === 'fail') ? 1 : undefined;
81
+ }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.milestoneRemove = milestoneRemove;
4
+ const config_1 = require("../lib/config");
5
+ const api_1 = require("../lib/api");
6
+ const resolve_1 = require("../lib/resolve");
7
+ const milestone_batch_1 = require("../lib/milestone-batch");
8
+ /** GET the milestone's canonical name; needed only when a UUID was passed. */
9
+ async function resolveMilestoneName(base, token, id, knownName) {
10
+ if (knownName)
11
+ return knownName;
12
+ const res = await fetch(`${base}/api/milestones/${id}`, {
13
+ headers: { Authorization: `Bearer ${token}` },
14
+ });
15
+ if (!res.ok) {
16
+ throw new Error(`milestone ${id} not found (HTTP ${res.status})`);
17
+ }
18
+ const { milestone } = (await res.json());
19
+ return milestone.name;
20
+ }
21
+ async function milestoneRemove(identifier, tasks, opts) {
22
+ const refs = (0, milestone_batch_1.dedupeTaskRefs)(tasks);
23
+ if (refs.length === 0) {
24
+ console.error('Error: provide at least one task (e.g. `LUM-1 LUM-2`).');
25
+ return 1;
26
+ }
27
+ const creds = (0, config_1.readCredentials)();
28
+ if (!creds) {
29
+ console.error('Error: not logged in. Run `lumo auth login` first.');
30
+ return 1;
31
+ }
32
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
33
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
34
+ let milestoneId;
35
+ let milestoneName;
36
+ let milestoneTasks;
37
+ try {
38
+ const resolved = await (0, resolve_1.resolveMilestoneId)(base, creds.token, identifier, opts.project);
39
+ milestoneId = resolved.id;
40
+ milestoneName = await resolveMilestoneName(base, creds.token, resolved.id, resolved.name);
41
+ const tasksRes = await fetch(`${base}/api/milestones/${milestoneId}/tasks`, { headers: { Authorization: `Bearer ${creds.token}` } });
42
+ if (!tasksRes.ok) {
43
+ throw new Error(`milestone tasks fetch failed (HTTP ${tasksRes.status})`);
44
+ }
45
+ const body = (await tasksRes.json());
46
+ milestoneTasks = body.tasks;
47
+ }
48
+ catch (err) {
49
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
50
+ return 1;
51
+ }
52
+ const { present, absent } = (0, milestone_batch_1.partitionMembership)(refs, milestoneTasks);
53
+ const outcomes = [];
54
+ for (const ref of present) {
55
+ try {
56
+ const res = await fetch(`${base}/api/tasks/by-identifier/${encodeURIComponent(ref)}`, {
57
+ method: 'PATCH',
58
+ headers: {
59
+ Authorization: `Bearer ${creds.token}`,
60
+ 'Content-Type': 'application/json',
61
+ },
62
+ body: JSON.stringify({ milestoneRef: null }),
63
+ });
64
+ if (res.ok) {
65
+ outcomes.push({ task: ref, status: 'ok' });
66
+ }
67
+ else {
68
+ let msg = `remove failed (HTTP ${res.status})`;
69
+ try {
70
+ const errBody = (await res.json());
71
+ if (errBody.error)
72
+ msg = errBody.error;
73
+ }
74
+ catch {
75
+ // non-JSON body; keep the status-only message
76
+ }
77
+ outcomes.push({ task: ref, status: 'fail', detail: msg });
78
+ }
79
+ }
80
+ catch (err) {
81
+ const msg = err instanceof Error ? err.message : String(err);
82
+ outcomes.push({ task: ref, status: 'fail', detail: msg });
83
+ }
84
+ }
85
+ for (const ref of absent) {
86
+ outcomes.push({
87
+ task: ref,
88
+ status: 'skip',
89
+ detail: 'not in this milestone',
90
+ });
91
+ }
92
+ process.stdout.write((0, milestone_batch_1.formatBatchSummary)({ milestoneName, verb: 'removed', outcomes }) + '\n');
93
+ return outcomes.some(o => o.status === 'fail') ? 1 : undefined;
94
+ }
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatMilestoneSummary = formatMilestoneSummary;
4
+ exports.milestoneSummary = milestoneSummary;
5
+ const config_1 = require("../lib/config");
6
+ const api_1 = require("../lib/api");
7
+ const resolve_1 = require("../lib/resolve");
8
+ const sanitize_1 = require("../lib/sanitize");
9
+ function formatMilestoneSummary(input) {
10
+ const { name, stats, tldr, report } = input;
11
+ const lines = [`Milestone: ${(0, sanitize_1.sanitizeField)(name)}`];
12
+ if (stats === null && tldr === null && report === null) {
13
+ lines.push('', '(no summary generated yet)');
14
+ return lines.join('\n');
15
+ }
16
+ if (stats !== null) {
17
+ lines.push(`Done: ${stats.done} / ${stats.total}`);
18
+ }
19
+ if (tldr) {
20
+ lines.push('', (0, sanitize_1.sanitizeField)(tldr));
21
+ }
22
+ if (report) {
23
+ lines.push('', (0, sanitize_1.sanitizeField)(report));
24
+ }
25
+ return lines.join('\n');
26
+ }
27
+ async function milestoneSummary(identifier, opts) {
28
+ const creds = (0, config_1.readCredentials)();
29
+ if (!creds) {
30
+ console.error('Error: not logged in. Run `lumo auth login` first.');
31
+ return 1;
32
+ }
33
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
34
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
35
+ let resolved;
36
+ try {
37
+ resolved = await (0, resolve_1.resolveMilestoneId)(base, creds.token, identifier, opts.project);
38
+ }
39
+ catch (err) {
40
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
41
+ return 1;
42
+ }
43
+ // Fill in name if identifier was a UUID (resolveMilestoneId returns '' then).
44
+ let displayName = resolved.name;
45
+ if (!displayName) {
46
+ const r = await fetch(`${base}/api/milestones/${resolved.id}`, {
47
+ headers: { Authorization: `Bearer ${creds.token}` },
48
+ });
49
+ if (r.ok) {
50
+ const { milestone } = (await r.json());
51
+ displayName = milestone.name;
52
+ }
53
+ }
54
+ // Optional retry: POST to /summary/retry first. Expect 202 (queued).
55
+ if (opts.retry) {
56
+ let retryRes;
57
+ try {
58
+ retryRes = await fetch(`${base}/api/milestones/${resolved.id}/summary/retry`, {
59
+ method: 'POST',
60
+ headers: { Authorization: `Bearer ${creds.token}` },
61
+ });
62
+ }
63
+ catch (err) {
64
+ const msg = err instanceof Error ? err.message : String(err);
65
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
66
+ return 1;
67
+ }
68
+ if (retryRes.status === 401) {
69
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
70
+ return 1;
71
+ }
72
+ if (!retryRes.ok && retryRes.status !== 202) {
73
+ let errMsg = `summary retry failed (HTTP ${retryRes.status})`;
74
+ try {
75
+ const errBody = (await retryRes.json());
76
+ if (errBody.error)
77
+ errMsg = errBody.error;
78
+ }
79
+ catch {
80
+ // ignore
81
+ }
82
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(errMsg)}`);
83
+ return 1;
84
+ }
85
+ // 202 means queued; regeneration is async. The GET below may still return
86
+ // the old summary or 404 — warn the user.
87
+ console.error('Note: summary regeneration queued. Result below may still be the previous summary.');
88
+ }
89
+ // GET /api/milestones/<id>/summary — returns MilestoneSummary row or 404.
90
+ let summaryRes;
91
+ try {
92
+ summaryRes = await fetch(`${base}/api/milestones/${resolved.id}/summary`, {
93
+ headers: { Authorization: `Bearer ${creds.token}` },
94
+ });
95
+ }
96
+ catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
99
+ return 1;
100
+ }
101
+ if (summaryRes.status === 401) {
102
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
103
+ return 1;
104
+ }
105
+ let stats = null;
106
+ let tldr = null;
107
+ let report = null;
108
+ if (summaryRes.ok) {
109
+ const body = (await summaryRes.json());
110
+ if (body.stats) {
111
+ stats = {
112
+ done: body.stats.done ?? 0,
113
+ total: body.stats.totalTasks ?? 0,
114
+ };
115
+ }
116
+ tldr = body.tldr ?? null;
117
+ report = body.report ?? null;
118
+ }
119
+ else if (summaryRes.status !== 404) {
120
+ let errMsg = `milestone summary failed (HTTP ${summaryRes.status})`;
121
+ try {
122
+ const errBody = (await summaryRes.json());
123
+ if (errBody.error)
124
+ errMsg = errBody.error;
125
+ }
126
+ catch {
127
+ // ignore
128
+ }
129
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(errMsg)}`);
130
+ return 1;
131
+ }
132
+ // 404 → fall through with all-null; formatMilestoneSummary renders the marker.
133
+ process.stdout.write(formatMilestoneSummary({
134
+ name: displayName,
135
+ stats,
136
+ tldr,
137
+ report,
138
+ }) + '\n');
139
+ }
@@ -35,11 +35,13 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.resolveAgentToken = resolveAgentToken;
37
37
  exports.setup = setup;
38
+ exports.installGitHook = installGitHook;
38
39
  const fs = __importStar(require("fs"));
39
40
  const os = __importStar(require("os"));
40
41
  const path = __importStar(require("path"));
41
42
  const child_process_1 = require("child_process");
42
43
  const hooks_template_1 = require("../lib/hooks-template");
44
+ const git_hook_template_1 = require("../lib/git-hook-template");
43
45
  const line_prompt_1 = require("../lib/line-prompt");
44
46
  const agent_1 = require("../lib/agent");
45
47
  const config_1 = require("../lib/config");
@@ -78,6 +80,9 @@ async function setup(options) {
78
80
  process.stdout.write(`\nInstalling Lumo for ${scope} scope: ${claudeDir}\n\n`);
79
81
  installSkill(claudeDir, options.force === true);
80
82
  mergeSettings(claudeDir, agentResult.token);
83
+ if (scope === 'project') {
84
+ installGitHook(root);
85
+ }
81
86
  printPostInstall();
82
87
  return 0;
83
88
  }
@@ -151,6 +156,64 @@ function mergeSettings(claudeDir, agentToken) {
151
156
  process.stdout.write(`✓ merged hooks into ${settingsPath} (agent=${agentToken}; added ${stats.addedEvents.length}, updated ${stats.updatedEvents.length}, kept ${stats.alreadyPresent.length})\n`);
152
157
  }
153
158
  }
159
+ function gitCapture(cwd, args) {
160
+ const res = (0, child_process_1.spawnSync)('git', args, { cwd, encoding: 'utf8' });
161
+ if (res.error || res.status !== 0)
162
+ return null;
163
+ return res.stdout.trim();
164
+ }
165
+ function installGitHook(projectRoot) {
166
+ if (gitCapture(projectRoot, ['rev-parse', '--is-inside-work-tree']) !== 'true') {
167
+ process.stdout.write('⚠ Not a git repository (or git unavailable) — skipped prepare-commit-msg hook.\n');
168
+ return;
169
+ }
170
+ const coreHooksPath = gitCapture(projectRoot, ['config', 'core.hooksPath']) || null;
171
+ if ((0, git_hook_template_1.resolveHookInstall)(coreHooksPath).mode === 'degrade') {
172
+ printGitHookManualInstructions(coreHooksPath);
173
+ return;
174
+ }
175
+ const hooksDirRaw = gitCapture(projectRoot, ['rev-parse', '--git-path', 'hooks']);
176
+ if (!hooksDirRaw) {
177
+ process.stdout.write('⚠ could not resolve git hooks dir — skipped prepare-commit-msg hook.\n');
178
+ return;
179
+ }
180
+ const absHooksDir = path.isAbsolute(hooksDirRaw)
181
+ ? hooksDirRaw
182
+ : path.join(projectRoot, hooksDirRaw);
183
+ const hookPath = path.join(absHooksDir, 'prepare-commit-msg');
184
+ const existing = fs.existsSync(hookPath)
185
+ ? fs.readFileSync(hookPath, 'utf8')
186
+ : null;
187
+ const { content, status } = (0, git_hook_template_1.mergeGitHook)(existing);
188
+ if (status === 'unchanged') {
189
+ process.stdout.write(`✓ prepare-commit-msg hook already up to date: ${hookPath}\n`);
190
+ return;
191
+ }
192
+ try {
193
+ fs.mkdirSync(absHooksDir, { recursive: true });
194
+ fs.writeFileSync(hookPath, content);
195
+ fs.chmodSync(hookPath, 0o755);
196
+ }
197
+ catch (err) {
198
+ const msg = err instanceof Error ? err.message : String(err);
199
+ process.stdout.write(`⚠ could not write prepare-commit-msg hook: ${msg}\n`);
200
+ return;
201
+ }
202
+ const verb = status === 'created'
203
+ ? 'wrote'
204
+ : status === 'appended'
205
+ ? 'appended to'
206
+ : 'updated';
207
+ process.stdout.write(`✓ ${verb} prepare-commit-msg hook: ${hookPath}\n`);
208
+ }
209
+ function printGitHookManualInstructions(coreHooksPath) {
210
+ process.stdout.write(`\n⚠ core.hooksPath is set to "${coreHooksPath}" (husky or a custom hooks dir).\n` +
211
+ ` Skipped auto-install to avoid clobbering it. To auto-tag commits with\n` +
212
+ ` [LUM-N], add this block to your prepare-commit-msg hook\n` +
213
+ ` (e.g. .husky/prepare-commit-msg), then make it executable:\n\n` +
214
+ git_hook_template_1.HOOK_SCRIPT_BLOCK +
215
+ `\n\n`);
216
+ }
154
217
  function printPostInstall() {
155
218
  const onPath = isLumoOnPath();
156
219
  const credsPath = path.join((0, config_1.configDir)(), 'credentials.json');
@@ -78,6 +78,9 @@ const milestone_create_1 = require("./commands/milestone-create");
78
78
  const milestone_show_1 = require("./commands/milestone-show");
79
79
  const milestone_update_1 = require("./commands/milestone-update");
80
80
  const milestone_delete_1 = require("./commands/milestone-delete");
81
+ const milestone_add_1 = require("./commands/milestone-add");
82
+ const milestone_remove_1 = require("./commands/milestone-remove");
83
+ const milestone_summary_1 = require("./commands/milestone-summary");
81
84
  const sprint_create_1 = require("./commands/sprint-create");
82
85
  const sprint_list_1 = require("./commands/sprint-list");
83
86
  const sprint_show_1 = require("./commands/sprint-show");
@@ -434,6 +437,22 @@ milestoneCmd
434
437
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
435
438
  .option('--yes', 'Required: confirm deletion without TTY prompt')
436
439
  .action(wrap((identifier, options) => (0, milestone_delete_1.milestoneDelete)(identifier, options)));
440
+ milestoneCmd
441
+ .command('add <identifier> <tasks...>')
442
+ .description('Bind one or more tasks to a milestone in one call. <identifier> accepts a name or UUID; each <task> accepts LUM-N or UUID. Per-task result with a tally; partial failures do not roll back.')
443
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
444
+ .action(wrap((identifier, tasks, options) => (0, milestone_add_1.milestoneAdd)(identifier, tasks, options)));
445
+ milestoneCmd
446
+ .command('remove <identifier> <tasks...>')
447
+ .description('Unbind one or more tasks from a milestone in one call. Tasks not currently in the milestone are skipped (idempotent), never reassigned. <identifier> accepts a name or UUID; each <task> accepts LUM-N or UUID.')
448
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
449
+ .action(wrap((identifier, tasks, options) => (0, milestone_remove_1.milestoneRemove)(identifier, tasks, options)));
450
+ milestoneCmd
451
+ .command('summary <identifier>')
452
+ .description('Show the AI-generated summary for a milestone. Identifier accepts a milestone name or UUID. Prints "(no summary generated yet)" when none exists. Use --retry to queue regeneration before fetching.')
453
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
454
+ .option('--retry', 'Trigger summary regeneration before fetching')
455
+ .action(wrap((identifier, options) => (0, milestone_summary_1.milestoneSummary)(identifier, options)));
437
456
  const sprintCmd = program
438
457
  .command('sprint')
439
458
  .description('Inspect sprints from the terminal');
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ // Idempotent install + content management for the Lumo `prepare-commit-msg`
3
+ // git hook. The hook block itself is pure POSIX sh + git — it extracts the
4
+ // `LUM-N` tag from the current branch name and appends it to the commit
5
+ // subject, with NO dependency on the `lumo` binary or any network call.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.HOOK_SCRIPT_BLOCK = exports.LUMO_HOOK_END = exports.LUMO_HOOK_BEGIN = void 0;
8
+ exports.mergeGitHook = mergeGitHook;
9
+ exports.resolveHookInstall = resolveHookInstall;
10
+ /** Markers delimiting the Lumo-managed block inside a prepare-commit-msg file. */
11
+ exports.LUMO_HOOK_BEGIN = '# >>> lumo prepare-commit-msg >>>';
12
+ exports.LUMO_HOOK_END = '# <<< lumo <<<';
13
+ const SHEBANG = '#!/bin/sh';
14
+ // The managed block. `$1` = commit-message file, `$2` = message source
15
+ // (message|template|merge|squash|commit). awk is POSIX-required and present
16
+ // wherever git runs. The grep guard makes the hook a no-op when the message
17
+ // already carries any [LUM-x] tag.
18
+ exports.HOOK_SCRIPT_BLOCK = `${exports.LUMO_HOOK_BEGIN}
19
+ # Auto-append the branch's task ID ([LUM-N]) to the commit subject when absent.
20
+ # Managed by \`lumo setup\` — edit between the markers at your own risk.
21
+ case "$2" in
22
+ merge|squash) exit 0 ;;
23
+ esac
24
+ lumo_branch=$(git symbolic-ref --short HEAD 2>/dev/null) || exit 0
25
+ lumo_id=$(printf '%s' "$lumo_branch" | grep -oE 'LUM-[0-9]+' | head -n1)
26
+ [ -z "$lumo_id" ] && exit 0
27
+ # Skip if the message already references any [LUM-x] anywhere (subject OR body)
28
+ # — avoids double-tagging when a body line already cites the task.
29
+ grep -qE '\\[LUM-[0-9]+\\]' "$1" && exit 0
30
+ lumo_tmp="$1.lumo.tmp"
31
+ awk -v id="$lumo_id" '
32
+ !done && $0 !~ /^#/ && $0 !~ /^[[:space:]]*$/ { $0 = $0 " [" id "]"; done = 1 }
33
+ { print }
34
+ ' "$1" > "$lumo_tmp" && mv "$lumo_tmp" "$1"
35
+ ${exports.LUMO_HOOK_END}`;
36
+ /**
37
+ * Idempotently fold the Lumo block into an existing prepare-commit-msg file.
38
+ * - null/blank existing → fresh file with shebang + block ('created')
39
+ * - existing has our markers → replace block in place ('updated' / 'unchanged')
40
+ * - existing foreign content → preserve it, append our block ('appended')
41
+ */
42
+ function mergeGitHook(existing) {
43
+ if (existing === null || existing.trim() === '') {
44
+ return { content: `${SHEBANG}\n${exports.HOOK_SCRIPT_BLOCK}\n`, status: 'created' };
45
+ }
46
+ const beginIdx = existing.indexOf(exports.LUMO_HOOK_BEGIN);
47
+ if (beginIdx !== -1) {
48
+ const endIdx = existing.indexOf(exports.LUMO_HOOK_END, beginIdx);
49
+ if (endIdx !== -1) {
50
+ const before = existing.slice(0, beginIdx);
51
+ const after = existing.slice(endIdx + exports.LUMO_HOOK_END.length);
52
+ const content = before + exports.HOOK_SCRIPT_BLOCK + after;
53
+ return {
54
+ content,
55
+ status: content === existing ? 'unchanged' : 'updated',
56
+ };
57
+ }
58
+ // BEGIN without END: corrupt block — fall through and append a clean one.
59
+ }
60
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
61
+ return {
62
+ content: `${existing}${sep}${exports.HOOK_SCRIPT_BLOCK}\n`,
63
+ status: 'appended',
64
+ };
65
+ }
66
+ /**
67
+ * Decide whether `lumo setup` can write the hook directly. When
68
+ * `core.hooksPath` is set (husky or a custom dir), writing into it risks being
69
+ * clobbered or landing where git won't run it — so we degrade to instructions.
70
+ */
71
+ function resolveHookInstall(coreHooksPath) {
72
+ if (coreHooksPath && coreHooksPath.trim() !== '')
73
+ return { mode: 'degrade' };
74
+ return { mode: 'install' };
75
+ }
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.dedupeTaskRefs = dedupeTaskRefs;
4
+ exports.partitionMembership = partitionMembership;
5
+ exports.formatBatchSummary = formatBatchSummary;
6
+ const sanitize_1 = require("./sanitize");
7
+ /**
8
+ * Trim, drop empties, and dedupe task refs case-insensitively while
9
+ * preserving input order and the first original spelling of each ref.
10
+ */
11
+ function dedupeTaskRefs(refs) {
12
+ const seen = new Set();
13
+ const out = [];
14
+ for (const raw of refs) {
15
+ const trimmed = raw.trim();
16
+ if (!trimmed)
17
+ continue;
18
+ const key = trimmed.toLowerCase();
19
+ if (seen.has(key))
20
+ continue;
21
+ seen.add(key);
22
+ out.push(trimmed);
23
+ }
24
+ return out;
25
+ }
26
+ /**
27
+ * Split requested task refs into those currently bound to the milestone
28
+ * (`present`) and those that are not (`absent`). A ref matches either the
29
+ * `<teamIdentifier>-<number>` identifier (case-insensitive) or the task id.
30
+ */
31
+ function partitionMembership(requested, milestoneTasks) {
32
+ const keys = new Set();
33
+ for (const t of milestoneTasks) {
34
+ keys.add(`${t.teamIdentifier}-${t.number}`.toUpperCase());
35
+ keys.add(t.id.toLowerCase());
36
+ }
37
+ const present = [];
38
+ const absent = [];
39
+ for (const ref of requested) {
40
+ const isMember = keys.has(ref.toUpperCase()) || keys.has(ref.toLowerCase());
41
+ if (isMember)
42
+ present.push(ref);
43
+ else
44
+ absent.push(ref);
45
+ }
46
+ return { present, absent };
47
+ }
48
+ const STATUS_GLYPH = {
49
+ ok: '✓',
50
+ skip: '-',
51
+ fail: '✗',
52
+ };
53
+ /**
54
+ * Render the batch result: a one-line tally header (omitting zero
55
+ * categories) followed by one line per task. Server-derived text (milestone
56
+ * name, failure detail) is sanitized to block ANSI injection.
57
+ */
58
+ function formatBatchSummary(args) {
59
+ const { verb, outcomes } = args;
60
+ const name = (0, sanitize_1.sanitizeField)(args.milestoneName);
61
+ const okCount = outcomes.filter(o => o.status === 'ok').length;
62
+ const skipCount = outcomes.filter(o => o.status === 'skip').length;
63
+ const failCount = outcomes.filter(o => o.status === 'fail').length;
64
+ const parts = [`${okCount} ${verb}`];
65
+ if (skipCount > 0)
66
+ parts.push(`${skipCount} skipped`);
67
+ if (failCount > 0)
68
+ parts.push(`${failCount} failed`);
69
+ const lines = [`${name}: ${parts.join(', ')}`];
70
+ for (const o of outcomes) {
71
+ const glyph = STATUS_GLYPH[o.status];
72
+ const detail = o.detail ? ` ${(0, sanitize_1.sanitizeField)(o.detail)}` : '';
73
+ lines.push(` ${glyph} ${o.task}${detail}`);
74
+ }
75
+ return lines.join('\n');
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",