@lumoai/cli 1.9.0 → 1.11.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 +139 -11
- package/dist/cli/src/commands/milestone-add.js +81 -0
- package/dist/cli/src/commands/milestone-remove.js +94 -0
- package/dist/cli/src/commands/milestone-summary.js +139 -0
- package/dist/cli/src/commands/next.js +103 -0
- package/dist/cli/src/commands/session-wrap.js +10 -5
- package/dist/cli/src/commands/setup.js +63 -0
- package/dist/cli/src/commands/wrap/memory-review-section.js +81 -0
- package/dist/cli/src/index.js +25 -0
- package/dist/cli/src/lib/git-hook-template.js +75 -0
- package/dist/cli/src/lib/memory-content.js +7 -0
- package/dist/cli/src/lib/milestone-batch.js +76 -0
- package/dist/cli/src/lib/rank-tasks.js +80 -0
- package/dist/cli/src/lib/session-memory-api.js +47 -0
- package/package.json +1 -1
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", "进度评论", "lumo next", "next task", "what''s next", "what should I work on", "recommend a task", "推荐下一个任务", "pick my next task".'
|
|
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
|
|
|
@@ -360,6 +370,44 @@ Filtering is currently client-side — the server returns the full "my tasks" se
|
|
|
360
370
|
- The user asks "what am I working on", "what tasks do I have", "list my tasks", "show me my queue".
|
|
361
371
|
- Before suggesting a status change ("mark something as done"), if no task ID is in context — run `task list` first to surface candidates.
|
|
362
372
|
|
|
373
|
+
### `lumo next [--count <N>]` — recommend the next task to work on
|
|
374
|
+
|
|
375
|
+
Ranks the tasks assigned to you and prints the top N (default 3), each with a
|
|
376
|
+
one-line reason. Read-only — it does **not** bind or load context. Pick one from
|
|
377
|
+
the list, then run `lumo session attach <LUM-N>` + `lumo task context <LUM-N>`.
|
|
378
|
+
|
|
379
|
+
Ranking is lexicographic: **priority** (URGENT→LOW) first, then **active-sprint
|
|
380
|
+
membership**, then **due date** (earlier first), then in-flight status
|
|
381
|
+
(IN_PROGRESS / IN_REVIEW ahead of TODO). DONE tasks are excluded. The active
|
|
382
|
+
sprint lookup is best-effort — if it fails the command still recommends, just
|
|
383
|
+
without the sprint boost.
|
|
384
|
+
|
|
385
|
+
| Flag | Type | Notes |
|
|
386
|
+
| ---- | ---- | ----- |
|
|
387
|
+
| `-n, --count <N>` | integer | How many tasks to recommend. Defaults to 3. Must be a positive integer. |
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
lumo next
|
|
391
|
+
lumo next --count 1
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Output:
|
|
395
|
+
|
|
396
|
+
```
|
|
397
|
+
Top 3 recommended tasks (of 12 open):
|
|
398
|
+
|
|
399
|
+
1. LUM-42 IN_PROGRESS URGENT Fix Slack OAuth redirect
|
|
400
|
+
↳ URGENT · active sprint · due 2026-06-03 (overdue) · in progress
|
|
401
|
+
2. LUM-48 TODO HIGH Investigate slow query
|
|
402
|
+
↳ HIGH · active sprint
|
|
403
|
+
3. LUM-12 TODO MEDIUM Add rate limiting
|
|
404
|
+
↳ MEDIUM · due 2026-06-10
|
|
405
|
+
|
|
406
|
+
Next: lumo session attach LUM-42 && lumo task context LUM-42
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
When to suggest: the user asks "what should I work on", "what's next", "推荐下一个任务", "pick my next task", or starts a session without a task in mind. After they choose, run `session attach` + `task context` for the picked task.
|
|
410
|
+
|
|
363
411
|
### `lumo task show <identifier>` — print one task's detail
|
|
364
412
|
|
|
365
413
|
Returns a key:value block for a single task — title, status, priority, project, assignee (with display name from Clerk), URL, and the full description below. Lighter than `task context` because it does not load prior session summaries or memory.
|
|
@@ -585,6 +633,67 @@ Requires `--yes`. No interactive prompt — CLI is agent-friendly. Tasks under t
|
|
|
585
633
|
lumo milestone delete "Q3 Launch" --yes
|
|
586
634
|
```
|
|
587
635
|
|
|
636
|
+
### `lumo milestone add <identifier> <task...>` — bind tasks to a milestone (batch)
|
|
637
|
+
|
|
638
|
+
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.
|
|
639
|
+
|
|
640
|
+
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.
|
|
641
|
+
|
|
642
|
+
```bash
|
|
643
|
+
lumo milestone add "Q3 Launch" LUM-1 LUM-2 LUM-3
|
|
644
|
+
lumo milestone add 11111111-2222-3333-4444-555555555555 LUM-1 LUM-2
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
Output — a tally header (zero categories omitted) plus one line per task:
|
|
648
|
+
|
|
649
|
+
```
|
|
650
|
+
Q3 Launch: 2 added, 1 failed
|
|
651
|
+
✓ LUM-1
|
|
652
|
+
✓ LUM-2
|
|
653
|
+
✗ LUM-3 no milestone matches "Q3 Launch" in this project. Try `lumo milestone list`.
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
### `lumo milestone remove <identifier> <task...>` — unbind tasks from a milestone (batch)
|
|
657
|
+
|
|
658
|
+
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.
|
|
659
|
+
|
|
660
|
+
```bash
|
|
661
|
+
lumo milestone remove "Q3 Launch" LUM-1 LUM-5
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Output — `✓` removed, `-` skipped (not in milestone), `✗` failed:
|
|
665
|
+
|
|
666
|
+
```
|
|
667
|
+
Q3 Launch: 1 removed, 1 skipped
|
|
668
|
+
✓ LUM-1
|
|
669
|
+
- LUM-5 not in this milestone
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### When to suggest `milestone add` / `milestone remove`
|
|
673
|
+
|
|
674
|
+
- 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.
|
|
675
|
+
- `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.
|
|
676
|
+
- For one-at-a-time sprint binding see `lumo sprint add / remove` (sprint batch is not yet supported).
|
|
677
|
+
|
|
678
|
+
### `lumo milestone summary <identifier> [--retry]` — fetch AI-generated milestone retro
|
|
679
|
+
|
|
680
|
+
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)`.
|
|
681
|
+
|
|
682
|
+
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)`.
|
|
683
|
+
|
|
684
|
+
| Flag | Type | Notes |
|
|
685
|
+
| ----------------- | ------- | ---------------------------------------------------------------- |
|
|
686
|
+
| `--project <ref>` | string | Project name or slug. Required when identifier is a name and the workspace has >1 project. |
|
|
687
|
+
| `--retry` | boolean | Queue a regeneration (async, server returns 202) before fetching. Only valid on a COMPLETED milestone. |
|
|
688
|
+
|
|
689
|
+
```bash
|
|
690
|
+
lumo milestone summary "Q3 Launch"
|
|
691
|
+
lumo milestone summary "Q3 Launch" --retry
|
|
692
|
+
lumo milestone summary 11111111-2222-3333-4444-555555555555
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
When to suggest: user asks "summarize the milestone", "milestone retro", "give me a summary of the Q3 milestone", "里程碑总结", "里程碑复盘".
|
|
696
|
+
|
|
588
697
|
## Document Management
|
|
589
698
|
|
|
590
699
|
### `lumo doc create [title] [flags]` — create a new document
|
|
@@ -1164,27 +1273,46 @@ lumo session detach
|
|
|
1164
1273
|
|
|
1165
1274
|
When to suggest: the user wants to stop tagging the current session with the active task (e.g., switching to unrelated exploratory work without binding to a different task).
|
|
1166
1275
|
|
|
1167
|
-
### `lumo session wrap [--yes] [--dry-run]` —
|
|
1276
|
+
### `lumo session wrap [--yes] [--dry-run]` — wrap-up panel: progress comment + memory review
|
|
1168
1277
|
|
|
1169
|
-
Session-end wrap-up panel
|
|
1278
|
+
Session-end wrap-up panel with **two sections, run in order**:
|
|
1279
|
+
|
|
1280
|
+
**1. 进度评论** — reads back the current Claude Code session's per-turn
|
|
1170
1281
|
`turnSummary` rows (the one-line Chinese summaries written each STOP), aggregates
|
|
1171
1282
|
every turn **since the last progress comment** into one bulleted body, and — after
|
|
1172
1283
|
a `[y] 发送 / [e] 编辑 / [s] 跳过` confirmation — posts it as a comment on the
|
|
1173
1284
|
session's bound task. A server-side watermark (`Session.lastProgressCommentAt`)
|
|
1174
1285
|
means re-running never re-posts the same turns.
|
|
1175
1286
|
|
|
1287
|
+
**2. 记忆审阅** — lists the Layer1 memories this session sedimented since the
|
|
1288
|
+
last review (deduped by a per-session watermark `Session.lastMemoryReviewAt`).
|
|
1289
|
+
Each new memory is shown as `[SCOPE] CATEGORY headline`, numbered from 1. You
|
|
1290
|
+
curate with a single line: `d 1,3` deletes rows 1 and 3, `p 2` promotes row 2 to
|
|
1291
|
+
project scope, and they combine (`d 1,3 p 2`). **回车 (empty) keeps all**; `s`
|
|
1292
|
+
skips the section. Keeping all (回车 or `--yes`) still **advances the watermark**
|
|
1293
|
+
so the next wrap won't re-list reviewed memories; `s` leaves them for next time.
|
|
1294
|
+
Out-of-range indices are ignored. Deletes/promotes run server-side, scoped to
|
|
1295
|
+
memories this session created (you can't touch other sessions' memories through
|
|
1296
|
+
this panel). With no new memories the section prints "(无内容)" and does nothing.
|
|
1297
|
+
|
|
1176
1298
|
```bash
|
|
1177
|
-
lumo session wrap # interactive: preview
|
|
1178
|
-
lumo session wrap --yes #
|
|
1179
|
-
lumo session wrap --dry-run # print
|
|
1299
|
+
lumo session wrap # interactive: preview each section, choose per-section
|
|
1300
|
+
lumo session wrap --yes # progress comment posted + memories all kept, no prompting (agent-friendly)
|
|
1301
|
+
lumo session wrap --dry-run # print both drafts only; never posts, never mutates, never advances watermarks
|
|
1180
1302
|
```
|
|
1181
1303
|
|
|
1182
1304
|
- Requires `$CLAUDE_CODE_SESSION_ID` (must run inside Claude Code) and a bound
|
|
1183
1305
|
task (`lumo session attach <LUM-N>` first). With no bound task or no new turn
|
|
1184
|
-
summaries, the
|
|
1185
|
-
- `[e] 编辑` opens `$EDITOR` (fallback vi/nano) on the drafted body;
|
|
1186
|
-
text is posted and the watermark still advances to the turns the
|
|
1187
|
-
|
|
1306
|
+
summaries, the 进度评论 section prints "(无内容)" and posts nothing.
|
|
1307
|
+
- `[e] 编辑` (进度评论) opens `$EDITOR` (fallback vi/nano) on the drafted body;
|
|
1308
|
+
the edited text is posted and the watermark still advances to the turns the
|
|
1309
|
+
draft covered.
|
|
1310
|
+
- `--yes` applies to both sections: posts the progress comment AND keeps all
|
|
1311
|
+
memories (no deletes/promotes) while advancing the memory-review watermark.
|
|
1312
|
+
- `--dry-run` prints both drafts; never posts, never mutates memories, never
|
|
1313
|
+
advances either watermark.
|
|
1314
|
+
- Non-TTY without `--yes`: prints the drafts and does **not** post or mutate
|
|
1315
|
+
(safe default).
|
|
1188
1316
|
|
|
1189
1317
|
When to suggest: at the end of a working session on a bound task, to record what
|
|
1190
1318
|
was done as a progress comment — offer `lumo session wrap` rather than composing
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.nextCommand = nextCommand;
|
|
4
|
+
exports.formatNextOutput = formatNextOutput;
|
|
5
|
+
const config_1 = require("../lib/config");
|
|
6
|
+
const api_1 = require("../lib/api");
|
|
7
|
+
const sanitize_1 = require("../lib/sanitize");
|
|
8
|
+
const rank_tasks_1 = require("../lib/rank-tasks");
|
|
9
|
+
/**
|
|
10
|
+
* `lumo next [-n, --count <N>]` — recommend the next task(s) to work on.
|
|
11
|
+
*
|
|
12
|
+
* Chains two read-only endpoints: active sprint ids (for a ranking boost) and
|
|
13
|
+
* "my tasks". The active-sprints call is non-fatal — if it fails we warn and
|
|
14
|
+
* rank without the sprint boost rather than aborting.
|
|
15
|
+
*/
|
|
16
|
+
async function nextCommand(opts) {
|
|
17
|
+
const count = opts.count !== undefined ? parseInt(opts.count, 10) : 3;
|
|
18
|
+
if (Number.isNaN(count) || count < 1) {
|
|
19
|
+
console.error(`Error: invalid --count "${opts.count}" (expected a positive integer)`);
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
const creds = (0, config_1.readCredentials)();
|
|
23
|
+
if (!creds) {
|
|
24
|
+
console.error('Error: not logged in. Run `lumo auth login` first.');
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
|
|
28
|
+
const base = (0, api_1.trimTrailingSlash)(apiUrl);
|
|
29
|
+
const authHeaders = { Authorization: `Bearer ${creds.token}` };
|
|
30
|
+
// 1. Active sprint ids — non-fatal. Failure just drops the sprint boost.
|
|
31
|
+
let activeSprintIds = new Set();
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`${base}/api/me/active-sprints`, {
|
|
34
|
+
headers: authHeaders,
|
|
35
|
+
});
|
|
36
|
+
if (res.ok) {
|
|
37
|
+
const data = (await res.json());
|
|
38
|
+
activeSprintIds = new Set(data.sprintIds);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
console.error(`Warning: could not load active sprints (HTTP ${res.status}); ranking without sprint boost.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
46
|
+
console.error(`Warning: could not load active sprints (${msg}); ranking without sprint boost.`);
|
|
47
|
+
}
|
|
48
|
+
// 2. My tasks — fatal on failure.
|
|
49
|
+
let res;
|
|
50
|
+
try {
|
|
51
|
+
res = await fetch(`${base}/api/tasks/me`, { headers: authHeaders });
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
55
|
+
console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
if (res.status === 401) {
|
|
59
|
+
console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
console.error(`Error: task fetch failed (HTTP ${res.status})`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const data = (await res.json());
|
|
67
|
+
const open = data.tasks.filter(t => t.status !== 'DONE');
|
|
68
|
+
const ranked = (0, rank_tasks_1.rankTasks)(open, activeSprintIds, new Date()).slice(0, count);
|
|
69
|
+
process.stdout.write(formatNextOutput(ranked, open.length) + '\n');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Render the ranked recommendation block. `ranked` is already sliced to the
|
|
73
|
+
* requested count; `totalOpen` is the count of all non-DONE tasks (for the
|
|
74
|
+
* "(of N open)" header). Pure — no I/O.
|
|
75
|
+
*/
|
|
76
|
+
function formatNextOutput(ranked, totalOpen) {
|
|
77
|
+
const first = ranked[0];
|
|
78
|
+
if (!first)
|
|
79
|
+
return 'No open tasks assigned to you. 🎉';
|
|
80
|
+
const widths = {
|
|
81
|
+
identifier: Math.max(...ranked.map(r => r.identifier.length)),
|
|
82
|
+
status: Math.max(...ranked.map(r => r.task.status.length)),
|
|
83
|
+
priority: Math.max(...ranked.map(r => r.task.priority.length)),
|
|
84
|
+
};
|
|
85
|
+
const plural = ranked.length === 1 ? '' : 's';
|
|
86
|
+
const lines = [
|
|
87
|
+
`Top ${ranked.length} recommended task${plural} (of ${totalOpen} open):`,
|
|
88
|
+
'',
|
|
89
|
+
];
|
|
90
|
+
ranked.forEach((r, i) => {
|
|
91
|
+
lines.push(`${i + 1}. ${r.identifier.padEnd(widths.identifier)} ` +
|
|
92
|
+
`${r.task.status.padEnd(widths.status)} ` +
|
|
93
|
+
`${r.task.priority.padEnd(widths.priority)} ` +
|
|
94
|
+
(0, sanitize_1.sanitizeField)(r.task.title));
|
|
95
|
+
lines.push(` ↳ ${r.reasons.join(' · ')}`);
|
|
96
|
+
});
|
|
97
|
+
lines.push('');
|
|
98
|
+
lines.push(`Next: lumo session attach ${first.identifier} && lumo task context ${first.identifier}`);
|
|
99
|
+
if (ranked.length > 1) {
|
|
100
|
+
lines.push('(也可换成列表里任意一个 LUM-N)');
|
|
101
|
+
}
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
@@ -4,13 +4,15 @@ exports.sessionWrap = sessionWrap;
|
|
|
4
4
|
const config_1 = require("../lib/config");
|
|
5
5
|
const wrap_panel_1 = require("../lib/wrap-panel");
|
|
6
6
|
const progress_comment_section_1 = require("./wrap/progress-comment-section");
|
|
7
|
+
const memory_review_section_1 = require("./wrap/memory-review-section");
|
|
7
8
|
/**
|
|
8
9
|
* `lumo session wrap [--yes] [--dry-run]`
|
|
9
10
|
*
|
|
10
|
-
* Session-end wrap-up panel
|
|
11
|
-
* from this session's unposted turnSummaries and post it
|
|
12
|
-
* confirmation) to the bound task
|
|
13
|
-
*
|
|
11
|
+
* Session-end wrap-up panel with two sections, run in order: (1) draft a
|
|
12
|
+
* progress comment from this session's unposted turnSummaries and post it
|
|
13
|
+
* (after y/e/s confirmation) to the bound task; (2) review the Layer1 memories
|
|
14
|
+
* this session sedimented — keep/delete/promote, deduped by a per-session
|
|
15
|
+
* watermark.
|
|
14
16
|
*/
|
|
15
17
|
async function sessionWrap(options) {
|
|
16
18
|
const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
|
|
@@ -24,7 +26,10 @@ async function sessionWrap(options) {
|
|
|
24
26
|
console.error('Error: not logged in. Run `lumo auth login` first.');
|
|
25
27
|
return 1;
|
|
26
28
|
}
|
|
27
|
-
const sections = [
|
|
29
|
+
const sections = [
|
|
30
|
+
new progress_comment_section_1.ProgressCommentSection({ creds, sessionId }),
|
|
31
|
+
new memory_review_section_1.MemoryReviewSection({ creds, sessionId }),
|
|
32
|
+
];
|
|
28
33
|
await (0, wrap_panel_1.runWrapPanel)(sections, {
|
|
29
34
|
yes: options.yes === true,
|
|
30
35
|
dryRun: options.dryRun === true,
|
|
@@ -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');
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MemoryReviewSection = void 0;
|
|
4
|
+
exports.parseReviewInstruction = parseReviewInstruction;
|
|
5
|
+
const sanitize_1 = require("../../lib/sanitize");
|
|
6
|
+
const line_prompt_1 = require("../../lib/line-prompt");
|
|
7
|
+
const memory_content_1 = require("../../lib/memory-content");
|
|
8
|
+
const session_memory_api_1 = require("../../lib/session-memory-api");
|
|
9
|
+
/** Parse a one-line review instruction into 0-based row indices. */
|
|
10
|
+
function parseReviewInstruction(line) {
|
|
11
|
+
const result = { deleteIdx: [], promoteIdx: [] };
|
|
12
|
+
const re = /([dp])\s*([\d,\s]+)/gi;
|
|
13
|
+
let m;
|
|
14
|
+
while ((m = re.exec(line)) !== null) {
|
|
15
|
+
const nums = m[2]
|
|
16
|
+
.split(/[\s,]+/)
|
|
17
|
+
.map(s => s.trim())
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map(s => parseInt(s, 10) - 1)
|
|
20
|
+
.filter(n => Number.isInteger(n) && n >= 0);
|
|
21
|
+
if (m[1].toLowerCase() === 'd')
|
|
22
|
+
result.deleteIdx.push(...nums);
|
|
23
|
+
else
|
|
24
|
+
result.promoteIdx.push(...nums);
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Wrap-panel section that lists the Layer1 memories this session sedimented and
|
|
30
|
+
* lets the user delete noise / promote keepers to project scope. Keeps its own
|
|
31
|
+
* draft state between prepare() and run(). Dedup is server-side via watermark.
|
|
32
|
+
*/
|
|
33
|
+
class MemoryReviewSection {
|
|
34
|
+
deps;
|
|
35
|
+
title = '记忆审阅';
|
|
36
|
+
draft = null;
|
|
37
|
+
constructor(deps) {
|
|
38
|
+
this.deps = deps;
|
|
39
|
+
}
|
|
40
|
+
async prepare() {
|
|
41
|
+
this.draft = await (0, session_memory_api_1.fetchMemoryDraft)(this.deps.creds, this.deps.sessionId);
|
|
42
|
+
return this.draft.memories.length > 0;
|
|
43
|
+
}
|
|
44
|
+
async run(opts) {
|
|
45
|
+
const draft = this.draft;
|
|
46
|
+
if (!draft || !draft.watermark || draft.memories.length === 0)
|
|
47
|
+
return;
|
|
48
|
+
process.stdout.write(`本次会话新增了这 ${draft.memories.length} 条 memory:\n`);
|
|
49
|
+
process.stdout.write(`${(0, sanitize_1.sanitizeField)((0, memory_content_1.formatMemoryReviewList)(draft.memories))}\n`);
|
|
50
|
+
if (opts.dryRun) {
|
|
51
|
+
process.stdout.write('(dry-run,未改动)\n');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (opts.yes) {
|
|
55
|
+
await this.apply(draft.watermark, [], []);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const line = (await (0, line_prompt_1.promptLine)('[回车] 全部保留 [d 1,3] 删除 [p 2] 提升到项目级 [s] 跳过 > ')).trim();
|
|
59
|
+
if (line.toLowerCase() === 's') {
|
|
60
|
+
process.stdout.write('已跳过本节。\n');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (line === '') {
|
|
64
|
+
await this.apply(draft.watermark, [], []);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const { deleteIdx, promoteIdx } = parseReviewInstruction(line);
|
|
68
|
+
const inRange = (n) => n >= 0 && n < draft.memories.length;
|
|
69
|
+
const deleteIds = deleteIdx.filter(inRange).map(i => draft.memories[i].id);
|
|
70
|
+
const promoteIds = promoteIdx
|
|
71
|
+
.filter(inRange)
|
|
72
|
+
.map(i => draft.memories[i].id)
|
|
73
|
+
.filter(id => !deleteIds.includes(id));
|
|
74
|
+
await this.apply(draft.watermark, deleteIds, promoteIds);
|
|
75
|
+
}
|
|
76
|
+
async apply(watermark, deleteIds, promoteIds) {
|
|
77
|
+
const { deleted, promoted } = await (0, session_memory_api_1.applyMemoryReview)(this.deps.creds, this.deps.sessionId, { watermark, deleteIds, promoteIds });
|
|
78
|
+
process.stdout.write(`已删除 ${deleted} 条,提升 ${promoted} 条到项目级。\n`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
exports.MemoryReviewSection = MemoryReviewSection;
|
package/dist/cli/src/index.js
CHANGED
|
@@ -46,6 +46,7 @@ const session_attach_1 = require("./commands/session-attach");
|
|
|
46
46
|
const session_detach_1 = require("./commands/session-detach");
|
|
47
47
|
const session_status_1 = require("./commands/session-status");
|
|
48
48
|
const session_wrap_1 = require("./commands/session-wrap");
|
|
49
|
+
const next_1 = require("./commands/next");
|
|
49
50
|
const task_context_1 = require("./commands/task-context");
|
|
50
51
|
const task_create_1 = require("./commands/task-create");
|
|
51
52
|
const task_update_1 = require("./commands/task-update");
|
|
@@ -78,6 +79,9 @@ const milestone_create_1 = require("./commands/milestone-create");
|
|
|
78
79
|
const milestone_show_1 = require("./commands/milestone-show");
|
|
79
80
|
const milestone_update_1 = require("./commands/milestone-update");
|
|
80
81
|
const milestone_delete_1 = require("./commands/milestone-delete");
|
|
82
|
+
const milestone_add_1 = require("./commands/milestone-add");
|
|
83
|
+
const milestone_remove_1 = require("./commands/milestone-remove");
|
|
84
|
+
const milestone_summary_1 = require("./commands/milestone-summary");
|
|
81
85
|
const sprint_create_1 = require("./commands/sprint-create");
|
|
82
86
|
const sprint_list_1 = require("./commands/sprint-list");
|
|
83
87
|
const sprint_show_1 = require("./commands/sprint-show");
|
|
@@ -181,6 +185,11 @@ program
|
|
|
181
185
|
.option('--force', 'Overwrite an existing SKILL.md when its contents differ from the bundled version')
|
|
182
186
|
.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.')
|
|
183
187
|
.action(wrap(options => (0, setup_1.setup)(options)));
|
|
188
|
+
program
|
|
189
|
+
.command('next')
|
|
190
|
+
.description('Recommend the next task(s) to work on, ranked by priority, active sprint, and due date. Prints top N (default 3); pick one and run `session attach` + `task context`.')
|
|
191
|
+
.option('-n, --count <N>', 'Number of tasks to recommend (default 3)')
|
|
192
|
+
.action(wrap(options => (0, next_1.nextCommand)(options)));
|
|
184
193
|
const session = program
|
|
185
194
|
.command('session')
|
|
186
195
|
.description('Manage per-terminal coding-session context');
|
|
@@ -434,6 +443,22 @@ milestoneCmd
|
|
|
434
443
|
.option('--project <ref>', 'Project name or slug (when identifier is a name)')
|
|
435
444
|
.option('--yes', 'Required: confirm deletion without TTY prompt')
|
|
436
445
|
.action(wrap((identifier, options) => (0, milestone_delete_1.milestoneDelete)(identifier, options)));
|
|
446
|
+
milestoneCmd
|
|
447
|
+
.command('add <identifier> <tasks...>')
|
|
448
|
+
.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.')
|
|
449
|
+
.option('--project <ref>', 'Project name or slug (when identifier is a name)')
|
|
450
|
+
.action(wrap((identifier, tasks, options) => (0, milestone_add_1.milestoneAdd)(identifier, tasks, options)));
|
|
451
|
+
milestoneCmd
|
|
452
|
+
.command('remove <identifier> <tasks...>')
|
|
453
|
+
.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.')
|
|
454
|
+
.option('--project <ref>', 'Project name or slug (when identifier is a name)')
|
|
455
|
+
.action(wrap((identifier, tasks, options) => (0, milestone_remove_1.milestoneRemove)(identifier, tasks, options)));
|
|
456
|
+
milestoneCmd
|
|
457
|
+
.command('summary <identifier>')
|
|
458
|
+
.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.')
|
|
459
|
+
.option('--project <ref>', 'Project name or slug (when identifier is a name)')
|
|
460
|
+
.option('--retry', 'Trigger summary regeneration before fetching')
|
|
461
|
+
.action(wrap((identifier, options) => (0, milestone_summary_1.milestoneSummary)(identifier, options)));
|
|
437
462
|
const sprintCmd = program
|
|
438
463
|
.command('sprint')
|
|
439
464
|
.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
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.buildMemoryContent = buildMemoryContent;
|
|
4
4
|
exports.formatMemoryList = formatMemoryList;
|
|
5
|
+
exports.formatMemoryReviewList = formatMemoryReviewList;
|
|
5
6
|
// Category/field metadata + builders for the `lumo memory` commands.
|
|
6
7
|
// Mirrors the four content shapes validated server-side by parseMemoryContent.
|
|
7
8
|
const sanitize_1 = require("./sanitize");
|
|
@@ -86,3 +87,9 @@ function formatMemoryList(rows) {
|
|
|
86
87
|
})
|
|
87
88
|
.join('\n');
|
|
88
89
|
}
|
|
90
|
+
/** Numbered review list: ` N. [SCOPE] CATEGORY headline`. 1-indexed. */
|
|
91
|
+
function formatMemoryReviewList(rows) {
|
|
92
|
+
return rows
|
|
93
|
+
.map((r, i) => ` ${i + 1}. [${r.scope}] ${r.category} ${headline(r.category, r.content)}`)
|
|
94
|
+
.join('\n');
|
|
95
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pure ranking logic for `lumo next`. Given the caller's open tasks plus the
|
|
4
|
+
* set of ACTIVE sprint ids, produce a recommendation order and a per-task list
|
|
5
|
+
* of human-readable reason factors.
|
|
6
|
+
*
|
|
7
|
+
* Ordering is lexicographic (explainable over a tunable magic score), honoring
|
|
8
|
+
* "priority first": priority → active-sprint membership → dueDate → in-flight
|
|
9
|
+
* status → updatedAt desc tiebreak.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.rankTasks = rankTasks;
|
|
13
|
+
const PRIORITY_WEIGHT = {
|
|
14
|
+
URGENT: 0,
|
|
15
|
+
HIGH: 1,
|
|
16
|
+
MEDIUM: 2,
|
|
17
|
+
LOW: 3,
|
|
18
|
+
};
|
|
19
|
+
function priorityWeight(priority) {
|
|
20
|
+
return PRIORITY_WEIGHT[priority] ?? 99;
|
|
21
|
+
}
|
|
22
|
+
/** Earlier due first; missing/invalid dueDate sorts last. */
|
|
23
|
+
function dueValue(dueDate) {
|
|
24
|
+
if (!dueDate)
|
|
25
|
+
return Number.POSITIVE_INFINITY;
|
|
26
|
+
const t = new Date(dueDate).getTime();
|
|
27
|
+
return Number.isNaN(t) ? Number.POSITIVE_INFINITY : t;
|
|
28
|
+
}
|
|
29
|
+
const IN_FLIGHT = new Set(['IN_PROGRESS', 'IN_REVIEW']);
|
|
30
|
+
/** in-flight (0) before TODO (1) before anything else (2). */
|
|
31
|
+
function statusRank(status) {
|
|
32
|
+
if (IN_FLIGHT.has(status))
|
|
33
|
+
return 0;
|
|
34
|
+
if (status === 'TODO')
|
|
35
|
+
return 1;
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
function inActiveSprint(task, activeSprintIds) {
|
|
39
|
+
return task.sprintId !== null && activeSprintIds.has(task.sprintId);
|
|
40
|
+
}
|
|
41
|
+
function deriveReasons(task, activeSprintIds, now) {
|
|
42
|
+
const reasons = [task.priority];
|
|
43
|
+
if (inActiveSprint(task, activeSprintIds)) {
|
|
44
|
+
reasons.push('active sprint');
|
|
45
|
+
}
|
|
46
|
+
if (task.dueDate) {
|
|
47
|
+
const day = task.dueDate.slice(0, 10);
|
|
48
|
+
const due = new Date(task.dueDate).getTime();
|
|
49
|
+
const overdue = !Number.isNaN(due) && due < now.getTime();
|
|
50
|
+
reasons.push(overdue ? `due ${day} (overdue)` : `due ${day}`);
|
|
51
|
+
}
|
|
52
|
+
if (task.status === 'IN_PROGRESS')
|
|
53
|
+
reasons.push('in progress');
|
|
54
|
+
else if (task.status === 'IN_REVIEW')
|
|
55
|
+
reasons.push('in review');
|
|
56
|
+
return reasons;
|
|
57
|
+
}
|
|
58
|
+
function rankTasks(tasks, activeSprintIds, now) {
|
|
59
|
+
const sorted = [...tasks].sort((a, b) => {
|
|
60
|
+
const pw = priorityWeight(a.priority) - priorityWeight(b.priority);
|
|
61
|
+
if (pw !== 0)
|
|
62
|
+
return pw;
|
|
63
|
+
const sa = inActiveSprint(a, activeSprintIds) ? 0 : 1;
|
|
64
|
+
const sb = inActiveSprint(b, activeSprintIds) ? 0 : 1;
|
|
65
|
+
if (sa !== sb)
|
|
66
|
+
return sa - sb;
|
|
67
|
+
const dv = dueValue(a.dueDate) - dueValue(b.dueDate);
|
|
68
|
+
if (dv !== 0 && !Number.isNaN(dv))
|
|
69
|
+
return dv;
|
|
70
|
+
const st = statusRank(a.status) - statusRank(b.status);
|
|
71
|
+
if (st !== 0)
|
|
72
|
+
return st;
|
|
73
|
+
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
|
74
|
+
});
|
|
75
|
+
return sorted.map(task => ({
|
|
76
|
+
task,
|
|
77
|
+
identifier: `${task.teamIdentifier}-${task.number}`,
|
|
78
|
+
reasons: deriveReasons(task, activeSprintIds, now),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchMemoryDraft = fetchMemoryDraft;
|
|
4
|
+
exports.applyMemoryReview = applyMemoryReview;
|
|
5
|
+
const api_1 = require("./api");
|
|
6
|
+
function base(creds) {
|
|
7
|
+
return (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
|
|
8
|
+
}
|
|
9
|
+
/** GET the memory-review draft for the session. Throws on transport / non-200. */
|
|
10
|
+
async function fetchMemoryDraft(creds, sessionId) {
|
|
11
|
+
const url = `${base(creds)}/api/sessions/${encodeURIComponent(sessionId)}/session-memories`;
|
|
12
|
+
const res = await fetch(url, {
|
|
13
|
+
headers: { Authorization: `Bearer ${creds.token}` },
|
|
14
|
+
});
|
|
15
|
+
if (res.status === 401)
|
|
16
|
+
throw new Error('API key invalid or revoked. Run `lumo auth login`.');
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
throw new Error(`memory draft fetch failed (HTTP ${res.status})`);
|
|
19
|
+
return (await res.json());
|
|
20
|
+
}
|
|
21
|
+
/** POST the review (deletes + promotes + watermark). Throws server message on non-2xx. */
|
|
22
|
+
async function applyMemoryReview(creds, sessionId, payload) {
|
|
23
|
+
const url = `${base(creds)}/api/sessions/${encodeURIComponent(sessionId)}/memory-review`;
|
|
24
|
+
const res = await fetch(url, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
Authorization: `Bearer ${creds.token}`,
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify(payload),
|
|
31
|
+
});
|
|
32
|
+
if (res.status === 401)
|
|
33
|
+
throw new Error('API key invalid or revoked. Run `lumo auth login`.');
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
let serverMsg = null;
|
|
36
|
+
try {
|
|
37
|
+
const errBody = (await res.json());
|
|
38
|
+
if (typeof errBody.error === 'string')
|
|
39
|
+
serverMsg = errBody.error;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// body wasn't JSON
|
|
43
|
+
}
|
|
44
|
+
throw new Error(serverMsg ?? `memory review failed (HTTP ${res.status})`);
|
|
45
|
+
}
|
|
46
|
+
return (await res.json());
|
|
47
|
+
}
|