@lumoai/cli 1.19.0 → 1.20.1

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lumo
3
- description: 'Use the Lumo CLI to load task context, manage session bindings, and run tasks / projects / milestones / sprints / docs / memory from the terminal. Activate when: the user mentions a Lumo task identifier (LUM-42 etc.), asks to load task background/context, wants to bind/check/detach a Claude Code session''s task, is about to start work on a task, or wants to create/update/list/show/comment on tasks, projects, milestones, sprints, documents, artifacts, Figma links, or memory. Triggers on: "LUM-", "task context", "load context", "session start", "session attach", "session status", "session detach", "which task", "what task am I on", "work on LUM", "session wrap", "wrap up session", "进度评论", "卡住检测", "create task", "new task", "file a task", "list tasks", "my tasks", "show task", "view task", "comment on task", "update task", "change task status", "rename task", "reassign task", "mark task as done", "lumo next", "next task", "what should I work on", "推荐下一个任务", "list projects", "what projects", "milestone", "里程碑", "list/create/update/delete/show milestone", "set milestone", "attach/unbind milestone", "tasks in milestone", "search milestones", "find milestone", "milestone health", "at-risk", "overdue", "archive/unarchive milestone", "归档里程碑", "milestone summary", "里程碑复盘", "reorder/move milestone", "排序里程碑", "milestone add/remove", "挂任务到里程碑", "auth login", "log in", "logout", "sign out", "switch account", "whoami", "who am I", "current workspace", "登录", "切换账号", "create/update/list/show/delete doc", "write doc", "写文档", "新建文档", "修改文档", "查看文档", "bind/unbind doc", "把文档关联到任务", "doc scope", "personal/workspace doc", "tag", "add/remove tag", "标签", "share/unshare doc", "分享文档", "doc share-list", "viewer/editor/manager", "doc tree", "doc move", "move/reparent doc", "移动文档", "sprint", "冲刺", "迭代", "create/list/show/update/delete sprint", "start/close sprint", "开始/关闭冲刺", "add to sprint", "active sprints", "sprint summary", "冲刺总结", "把任务挂到冲刺", "sprint health", "sprint risk", "is this sprint at risk", "冲刺风险", "冲刺健康度", "sprint blockers", "冲刺阻塞", "lumo update", "upgrade lumo", "升级 lumo", "new lumo version", "lumo setup", "install lumo skill/hooks", "wire up lumo", "set up lumo", "安装 lumo", "配置 lumo", "task artifact", "artifact add/list/show/update/rm", "spec artifact", "record/attach spec", "attach plan", "记录 spec", "查看 artifact", figma, attach figma, figma link, 关联 figma, 设计稿, figma design, "memory", "记忆", "remember", "record a memory", "记一条", "promote memory", "promote to project", "沉淀", "task/project memory", "retrieval", "取全文", "拉全文", "task slack show", "看 thread", "show slack thread", "task web show", "web 正文", "task figma context", "figma metadata", "task comments list", "list comments", "看评论", "task pr show", "查看 PR", "show pr", "PR 详情", "import google doc", "sync google doc", "google drive", "doc import-gdoc", "doc sync", "导入/同步 google 文档", "mark blocked", "blocked tag", "标记 blocked", "stuck", "repeatedly failing", "worktree", "git worktree", "并行 worktree", "scaffold worktree", "新建 worktree", "node_modules 软链", "worktree 隔离", "lumo worktree add/rm/list".'
3
+ description: 'Use the Lumo CLI to load task context, manage session bindings, and run tasks / projects / milestones / sprints / docs / memory from the terminal. Activate when: the user mentions a Lumo task identifier (LUM-42 etc.), asks to load task background/context, wants to bind/check/detach a Claude Code session''s task, is about to start work on a task, or wants to create/update/list/show/comment on tasks, projects, milestones, sprints, documents, artifacts, Figma links, or memory. Triggers on: "LUM-", "task context", "load context", "session start", "session attach", "session status", "session detach", "which task", "what task am I on", "work on LUM", "session wrap", "wrap up session", "进度评论", "卡住检测", "fragment usage vote", "mark used fragments", "which fragments did I use", "--used", "上下文使用投票", "标记用过的记忆", "create task", "new task", "file a task", "list tasks", "my tasks", "show task", "view task", "comment on task", "update task", "change task status", "rename task", "reassign task", "mark task as done", "lumo next", "next task", "what should I work on", "推荐下一个任务", "list projects", "what projects", "milestone", "里程碑", "list/create/update/delete/show milestone", "set milestone", "attach/unbind milestone", "tasks in milestone", "search milestones", "find milestone", "milestone health", "at-risk", "overdue", "archive/unarchive milestone", "归档里程碑", "milestone summary", "里程碑复盘", "reorder/move milestone", "排序里程碑", "milestone add/remove", "挂任务到里程碑", "auth login", "log in", "logout", "sign out", "switch account", "whoami", "who am I", "current workspace", "登录", "切换账号", "create/update/list/show/delete doc", "write doc", "写文档", "新建文档", "修改文档", "查看文档", "bind/unbind doc", "把文档关联到任务", "doc scope", "personal/workspace doc", "tag", "add/remove tag", "标签", "share/unshare doc", "分享文档", "doc share-list", "viewer/editor/manager", "doc tree", "doc move", "move/reparent doc", "移动文档", "sprint", "冲刺", "迭代", "create/list/show/update/delete sprint", "start/close sprint", "开始/关闭冲刺", "add to sprint", "active sprints", "sprint summary", "冲刺总结", "把任务挂到冲刺", "sprint health", "sprint risk", "is this sprint at risk", "冲刺风险", "冲刺健康度", "sprint blockers", "冲刺阻塞", "lumo update", "upgrade lumo", "升级 lumo", "new lumo version", "lumo setup", "install lumo skill/hooks", "wire up lumo", "set up lumo", "安装 lumo", "配置 lumo", "task artifact", "artifact add/list/show/update/rm", "spec artifact", "record/attach spec", "attach plan", "记录 spec", "查看 artifact", figma, attach figma, figma link, 关联 figma, 设计稿, figma design, "memory", "记忆", "remember", "record a memory", "记一条", "promote memory", "promote to project", "沉淀", "task/project memory", "retrieval", "取全文", "拉全文", "task slack show", "看 thread", "show slack thread", "task web show", "web 正文", "task figma context", "figma metadata", "task comments list", "list comments", "看评论", "task pr show", "查看 PR", "show pr", "PR 详情", "import google doc", "sync google doc", "google drive", "doc import-gdoc", "doc sync", "导入/同步 google 文档", "mark blocked", "blocked tag", "标记 blocked", "stuck", "repeatedly failing", "worktree", "git worktree", "并行 worktree", "scaffold worktree", "新建 worktree", "node_modules 软链", "worktree 隔离", "lumo worktree add/rm/list", "task lineage", "lineage", "causal trail", "审计", "因果链", "成本归因", "trace context", "--signal", "usage signal health", "auto usage audit", "自动使用审计", "signal-health".'
4
4
  ---
5
5
 
6
6
  ## Prerequisites
@@ -29,7 +29,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
29
29
  | `doc*` | [references/docs.md](references/docs.md) |
30
30
  | `sprint*` | [references/sprints.md](references/sprints.md) |
31
31
  | `task/project memory`, `memory promote/rm` | [references/memory.md](references/memory.md) |
32
- | `session attach/status/detach/wrap`, auto-bind, Layer-2 review | [references/sessions.md](references/sessions.md) |
32
+ | `session attach/status/detach/wrap`, git-suggest on start, Layer-2 review | [references/sessions.md](references/sessions.md) |
33
33
  | `worktree add/rm/list` (local dev tooling) | [references/worktree.md](references/worktree.md) |
34
34
 
35
35
  ## Command catalog
@@ -49,6 +49,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
49
49
  - `lumo task figma context <id> <linkId>` — Figma link metadata (v1)
50
50
  - `lumo task comments list <id>` — full comment thread (read-only; ≠ `task comment`)
51
51
  - `lumo task pr show <id> <number>` — synced PR metadata (v1)
52
+ - `lumo task lineage <id>` — show the causal trail: fragments that fed the task + each one's outcome + the run's token/loop cost (read-only audit view); `lumo task lineage <id> --signal` also appends workspace-level usage signal-health (used distribution, per-session variance, used-vs-base merge rate)
52
53
 
53
54
  **Tasks** — see [tasks.md](references/tasks.md)
54
55
 
@@ -97,8 +98,8 @@ The command catalog below is a **map**: it lists every command grouped by domain
97
98
 
98
99
  - `lumo session attach <id>` — bind this session to a task (then run `task context`)
99
100
  - `lumo session status` / `lumo session detach` — show / clear binding
100
- - `lumo session wrap [--yes] [--dry-run]` — end-of-session panel: progress comment + memory review + blocked-tag prompt
101
- - Auto-bind at session start + Layer-2 project-memory review — see the reference
101
+ - `lumo session wrap [--yes] [--dry-run] [--used <indices>]` — end-of-session panel: progress comment + memory review + fragment-usage vote (`--used`, LUM-300) + blocked-tag prompt. Usage is now also audited automatically when a task reaches DONE (evidence-gated, true-only — confident fragments marked used, the rest left NULL); `session wrap --used` remains the manual override and takes precedence for a session.
102
+ - Git-suggest at session start (suggests `session attach`, never auto-binds) + Layer-2 project-memory review — see the reference
102
103
 
103
104
  **Worktrees (local dev tooling)** — see [worktree.md](references/worktree.md)
104
105
 
@@ -115,4 +116,4 @@ Typical flow when a user says "help me with LUM-42":
115
116
  3. Review unresolved items, PR-review todos, and the task description
116
117
  4. Begin working on the task
117
118
 
118
- **Auto-bind:** session-start may already bind the task from the git branch / recent commits and print `已自动绑定 LUM-N …`. If the user says "不是"/"不对"/"wrong task", run `lumo session detach` (then `session attach <LUM-N>` if they name the right one). See [sessions.md](references/sessions.md) for the full session-start behavior.
119
+ **Git-suggest at start:** when the session is unbound, session-start may infer the task from the git branch / recent commits and print a suggestion — `检测到 LUM-N 运行 lumo session attach LUM-N 绑定。` — **without** binding. Confirm it's the right task, then run `lumo session attach <LUM-N>` yourself (binding only happens on an explicit attach). See [sessions.md](references/sessions.md) for the full session-start behavior.
@@ -44,7 +44,7 @@ developer could learn from the source, git log, or docs. When unsure, don't.
44
44
 
45
45
  ### Which category
46
46
 
47
- - `trap` — a pitfall. Describe the PROBLEM ONLY (`--trigger`, `--outcome`); put any fix in a separate `procedural`.
47
+ - `trap` — a pitfall. Describe the problem (`--trigger`, `--outcome`); put a **one-line fix** inline via `--workaround`. Only when the fix is a genuine multi-step workflow, omit `--workaround` and add a separate `procedural` instead — never both (a `procedural` that just restates a trap's `--workaround` is a double-write).
48
48
  - `decision` — an engineering decision (`--what` + `--why`, optional `--alternatives`/`--implications`).
49
49
  - `convention` — a team rule (`--rule` + `--applies` = where it applies).
50
50
  - `procedural` — a reusable workflow (`--workflow` + `--trigger` + `--step`…).
@@ -2,26 +2,28 @@
2
2
 
3
3
  ## Session Management
4
4
 
5
- ### Auto-bind at session start (from local git)
5
+ ### Suggest-on-start from local git (no auto-bind)
6
6
 
7
7
  When a session starts **without** a bound task, the `session-start` hook tries to
8
- infer the task from local git before falling back to the "请告诉我任务编号" prompt:
8
+ infer the task from local git so it can **suggest** one it never binds for you
9
+ (LUM-302):
9
10
 
10
11
  - It reads the **current branch name** first (e.g. `lumo/LUM-145-...`), then the
11
12
  **most recent commit subjects** (e.g. `... [LUM-145]`), extracting the first
12
13
  `LUM-<n>`.
13
- - On a hit it binds the session to that task automatically (same `bind-task`
14
- endpoint as `session attach`) and prints a single line:
15
- `已自动绑定 LUM-145 - <title>(依据分支名/最近 commit)。如果不对,回复"不是"我就帮你解绑。`
16
- The freshly-bound task's memory is injected too.
14
+ - On a hit it prints a single suggestion line and stops the session stays
15
+ **unbound** and no context is injected yet:
16
+ `检测到 LUM-145(依据分支名/最近 commit)。运行 lumo session attach LUM-145 绑定。`
17
+ No task title is shown here because nothing was fetched; the title, memory,
18
+ and PR-review todos appear only once you actually attach.
17
19
  - No match (detached HEAD, a non-lumo branch with no tagged commits, not a git
18
- repo) or a failed bind (unknown task) → it degrades silently to the normal
19
- unbound prompt.
20
+ repo) → it degrades to the normal unbound prompt.
20
21
 
21
- **Agent guidance:** if the user responds "不是" / "不对" / "wrong task" to an
22
- auto-bind line, run `lumo session detach` to clear the binding (then `session
23
- attach <LUM-N>` if they name the right one). No detach is needed when the
24
- auto-bound task is correct.
22
+ **Agent guidance:** when you see a suggestion line, confirm the inferred task is
23
+ the one the user wants, then run `lumo session attach <LUM-N>` (followed by
24
+ `lumo task context <LUM-N>` to load the background). `session attach` is the
25
+ **only** path that binds — there is nothing to "undo" if the inference is wrong;
26
+ just attach the correct task instead.
25
27
 
26
28
  ### Layer 2 project-memory review at session start
27
29
 
@@ -62,7 +64,7 @@ lumo session attach LUM-42 # already on LUM-7 → prompts (TTY) / ref
62
64
  lumo session attach LUM-42 --force # overwrite without confirmation
63
65
  ```
64
66
 
65
- **Agent guidance:** when `session attach` reports the session is already bound to a different task, **ask the user** whether to switch before re-running with `--force`. Don't auto-`--force` — the existing binding may be intentional (e.g. an auto-bind from the branch name). Alternatively run `lumo session detach` first, then a clean `session attach`.
67
+ **Agent guidance:** when `session attach` reports the session is already bound to a different task, **ask the user** whether to switch before re-running with `--force`. Don't auto-`--force` — the existing binding may be intentional (e.g. a manual attach the user ran earlier). Alternatively run `lumo session detach` first, then a clean `session attach`.
66
68
 
67
69
  ### Parallel sessions
68
70
 
@@ -88,9 +90,9 @@ lumo session detach
88
90
 
89
91
  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).
90
92
 
91
- ### `lumo session wrap [--yes] [--dry-run]` — wrap-up panel: progress comment + memory review + blocked-tag prompt
93
+ ### `lumo session wrap [--yes] [--dry-run] [--used <indices>]` — wrap-up panel: progress comment + memory review + fragment-usage vote + blocked-tag prompt
92
94
 
93
- Session-end wrap-up panel with **three sections, run in order**:
95
+ Session-end wrap-up panel with **four sections, run in order**:
94
96
 
95
97
  **1. 进度评论** — reads back the current Claude Code session's per-turn
96
98
  `turnSummary` rows (the one-line Chinese summaries written each STOP), aggregates
@@ -110,7 +112,21 @@ Out-of-range indices are ignored. Deletes/promotes run server-side, scoped to
110
112
  memories this session created (you can't touch other sessions' memories through
111
113
  this panel). With no new memories the section prints "(无内容)" and does nothing.
112
114
 
113
- **3. 卡住检测 (blocked-tag prompt, LUM-153)** — if the **same kind of failure
115
+ **3. 上下文使用投票 (fragment-usage vote, LUM-300)** — lists the context
116
+ fragments this session **consumed** (its lineage edges: memory / slack / web /
117
+ figma / PR / review-todo / session), numbered from 1 with a content snippet
118
+ label. The agent records which it **actually used** via
119
+ `lumo session wrap --used <indices>` (1-based, comma/space separated; `--used
120
+ none` = used nothing). Voted fragments get `used=true`, the rest of the
121
+ session's fragments `used=false`. **Without `--used` the section only lists the
122
+ candidates and writes nothing** (edges stay `null` = not voted — honest, not
123
+ "unused"). A session that already voted (`usedAt` set) is skipped. **Why:** it
124
+ upgrades the flywheel signal from "co-loaded" (constant, no information) to
125
+ "actually used" (varies → discriminative); `task context` then prefers each
126
+ fragment's usage-based merge rate, falling back to the weaker presence rate when
127
+ usage samples are thin. With no consumed fragments the section prints "(无内容)".
128
+
129
+ **4. 卡住检测 (blocked-tag prompt, LUM-153)** — if the **same kind of failure
114
130
  recurred ≥ 3 times** in this session (server-aggregated from
115
131
  `POST_TOOL_USE_FAILURE` events grouped by tool name, plus `STOP_FAILURE`
116
132
  turn-level failures), the section surfaces the dominant failure (`卡在 <tool>
@@ -128,11 +144,18 @@ suggestion and moves on rather than silently flipping board state. When there's
128
144
  nothing to prompt, the section prints "(无内容)".
129
145
 
130
146
  ```bash
131
- lumo session wrap # interactive: preview each section, choose per-section
132
- lumo session wrap --yes # progress posted + memories kept; blocked tag NOT auto-applied (needs interactive y)
133
- lumo session wrap --dry-run # print all drafts only; never posts, never mutates, never advances watermarks
147
+ lumo session wrap # interactive: preview each section, choose per-section
148
+ lumo session wrap --yes # progress posted + memories kept; blocked tag NOT auto-applied (needs interactive y)
149
+ lumo session wrap --yes --used 1,3 # also record fragments 1 & 3 as used (the rest used=false)
150
+ lumo session wrap --used none # record that none of the injected fragments were used
151
+ lumo session wrap --dry-run # print all drafts only; never posts, never mutates, never advances watermarks
134
152
  ```
135
153
 
154
+ The usage vote is a two-step flow for agents: run `lumo session wrap` once to
155
+ see the numbered fragment list, decide which you actually used, then re-run with
156
+ `--used <indices>`. Re-running is safe — the other sections are watermark-guarded
157
+ (progress won't double-post, reviewed memories won't re-list).
158
+
136
159
  - Requires `$CLAUDE_CODE_SESSION_ID` (must run inside Claude Code) and a bound
137
160
  task (`lumo session attach <LUM-N>` first). With no bound task or no new turn
138
161
  summaries, the 进度评论 section prints "(无内容)" and posts nothing.
@@ -22,6 +22,7 @@ The command prints a markdown document to stdout containing:
22
22
  5. **Previous sessions** — ordered newest-first, each with:
23
23
  - A headline summary of what was done
24
24
  - Unresolved items (carry-over TODOs from that session)
25
+ 6. **飞轮信号 · 历史 merge 贡献** — appended at the very end. For each context fragment with enough history, a one-line historical merge-contribution signal (e.g. `出现在 5 个已闭合任务·4 merged (80%)`), computed from accumulated lineage edges. Denominator = distinct tasks where the fragment appeared with a resolved (non-UNKNOWN) outcome; only fragments with ≥3 such tasks are shown (cold-start gate), so the block is often absent early on. This is **historical correlation, not causation** — don't read it as a prediction.
25
26
 
26
27
  ### How to use the context
27
28
 
@@ -107,3 +108,28 @@ require the GitHub integration, so the command ends with a `note:` saying so.
107
108
  ```bash
108
109
  lumo task pr show LUM-42 128
109
110
  ```
111
+
112
+ ## `lumo task lineage <id>`
113
+
114
+ Read-only audit view over the task's `LineageEdge` rows. Given a task
115
+ identifier (`LUM-N`), prints the causal trail:
116
+
117
+ - **Totals banner** — distinct sessions, fragment count, edge count,
118
+ total tokens (input/output/cache split) and loops, and the outcome
119
+ distribution.
120
+ - **One block per session** — the group's cost shown **once** (token/loop),
121
+ the date it consumed context, then each context fragment as
122
+ `[OUTCOME] TYPE — <source label>`, plus a per-group outcome summary.
123
+
124
+ Cost is attributed once per session (a session that injected many fragments is
125
+ not double-counted). Fragment ids are canonical — MEMORY fragments survive
126
+ consolidation drift.
127
+
128
+ **Cold start:** a task with no edges prints a friendly note (lineage is captured
129
+ when a session-bound run consumes the task's context), not an error.
130
+
131
+ **When to suggest:** the user wants to audit "what context did the AI actually
132
+ use, and what did it cost" for a task / merged PR — CFO / compliance / trust
133
+ narratives.
134
+
135
+ Entry point is the task identifier only; PR-number lookup is a future addition.
@@ -5,16 +5,18 @@ 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
7
  const memory_review_section_1 = require("./wrap/memory-review-section");
8
+ const fragment_usage_section_1 = require("./wrap/fragment-usage-section");
8
9
  const blocked_prompt_section_1 = require("./wrap/blocked-prompt-section");
9
10
  /**
10
11
  * `lumo session wrap [--yes] [--dry-run]`
11
12
  *
12
- * Session-end wrap-up panel with three sections, run in order: (1) draft a
13
+ * Session-end wrap-up panel with four sections, run in order: (1) draft a
13
14
  * progress comment from this session's unposted turnSummaries and post it
14
15
  * (after y/e/s confirmation) to the bound task; (2) review the Layer1 memories
15
16
  * this session sedimented — keep/delete/promote, deduped by a per-session
16
- * watermark; (3) if the session repeatedly hit the same failure, prompt whether
17
- * to flag the bound task with a `blocked` tag (LUM-153, prompt-only).
17
+ * watermark; (3) vote which injected context fragments were actually used
18
+ * (LUM-300, via `--used`); (4) if the session repeatedly hit the same failure,
19
+ * prompt whether to flag the bound task with a `blocked` tag (LUM-153).
18
20
  */
19
21
  async function sessionWrap(options) {
20
22
  const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
@@ -31,6 +33,7 @@ async function sessionWrap(options) {
31
33
  const sections = [
32
34
  new progress_comment_section_1.ProgressCommentSection({ creds, sessionId }),
33
35
  new memory_review_section_1.MemoryReviewSection({ creds, sessionId }),
36
+ new fragment_usage_section_1.FragmentUsageSection({ creds, sessionId, used: options.used }),
34
37
  new blocked_prompt_section_1.BlockedPromptSection({ creds, sessionId }),
35
38
  ];
36
39
  await (0, wrap_panel_1.runWrapPanel)(sections, {
@@ -114,21 +114,28 @@ function formatTaskContextMarkdown(data, now) {
114
114
  lines.push('');
115
115
  lines.push('_No prior coding sessions for this task._');
116
116
  lines.push('');
117
- return lines.join('\n');
118
117
  }
119
- lines.push(`## Previous Sessions (${data.sessions.length})`);
120
- lines.push('');
121
- for (const s of data.sessions) {
122
- const shortId = s.id.slice(0, 8);
123
- const ago = (0, format_1.relativeTime)(new Date(s.lastActivityAt), now);
124
- const dur = (0, format_1.formatDuration)(s.durationMs);
125
- lines.push(`### Session ${shortId} · ${ago} · ${dur}`);
126
- lines.push(`**Summary**: ${(0, sanitize_1.sanitizeField)(s.headline)}`);
127
- if (s.unresolved.length > 0) {
128
- lines.push('**Unresolved**:');
129
- for (const u of s.unresolved)
130
- lines.push(`- ${(0, sanitize_1.sanitizeField)(u)}`);
118
+ else {
119
+ lines.push(`## Previous Sessions (${data.sessions.length})`);
120
+ lines.push('');
121
+ for (const s of data.sessions) {
122
+ const shortId = s.id.slice(0, 8);
123
+ const ago = (0, format_1.relativeTime)(new Date(s.lastActivityAt), now);
124
+ const dur = (0, format_1.formatDuration)(s.durationMs);
125
+ lines.push(`### Session ${shortId} · ${ago} · ${dur}`);
126
+ lines.push(`**Summary**: ${(0, sanitize_1.sanitizeField)(s.headline)}`);
127
+ if (s.unresolved.length > 0) {
128
+ lines.push('**Unresolved**:');
129
+ for (const u of s.unresolved)
130
+ lines.push(`- ${(0, sanitize_1.sanitizeField)(u)}`);
131
+ }
132
+ lines.push('');
131
133
  }
134
+ }
135
+ // Flywheel signal goes last so it reads as a closing "historical payoff"
136
+ // footer (LUM-278). Server returns "" below the cold-start floor.
137
+ if (data.lineageSection && data.lineageSection.trim().length > 0) {
138
+ lines.push((0, sanitize_1.sanitizeField)(data.lineageSection.trimEnd()));
132
139
  lines.push('');
133
140
  }
134
141
  return lines.join('\n');
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.taskLineage = taskLineage;
4
+ exports.formatLineageMarkdown = formatLineageMarkdown;
5
+ exports.formatSignalHealth = formatSignalHealth;
6
+ const config_1 = require("../lib/config");
7
+ const api_1 = require("../lib/api");
8
+ const sanitize_1 = require("../lib/sanitize");
9
+ async function taskLineage(identifier, opts) {
10
+ if (!identifier) {
11
+ console.error('Error: missing <identifier>. Usage: lumo task lineage <LUM-42>');
12
+ return 1;
13
+ }
14
+ const creds = (0, config_1.readCredentials)();
15
+ if (!creds) {
16
+ console.error('Error: not logged in. Run `lumo auth login` first.');
17
+ return 1;
18
+ }
19
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
20
+ const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/tasks/lineage/${encodeURIComponent(identifier)}`;
21
+ let res;
22
+ try {
23
+ res = await fetch(url, {
24
+ headers: { Authorization: `Bearer ${creds.token}` },
25
+ });
26
+ }
27
+ catch (err) {
28
+ const msg = err instanceof Error ? err.message : String(err);
29
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
30
+ return 1;
31
+ }
32
+ if (res.status === 401) {
33
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
34
+ return 1;
35
+ }
36
+ if (res.status === 404) {
37
+ console.error(`Error: task ${identifier} not found in workspace ${creds.workspaceSlug}`);
38
+ return 1;
39
+ }
40
+ if (!res.ok) {
41
+ console.error(`Error: lineage fetch failed (HTTP ${res.status})`);
42
+ return 1;
43
+ }
44
+ const data = (await res.json());
45
+ process.stdout.write(formatLineageMarkdown(data));
46
+ if (opts?.signal) {
47
+ const signalUrl = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/lineage/signal`;
48
+ let signalRes;
49
+ try {
50
+ signalRes = await fetch(signalUrl, {
51
+ headers: { Authorization: `Bearer ${creds.token}` },
52
+ });
53
+ }
54
+ catch (err) {
55
+ const msg = err instanceof Error ? err.message : String(err);
56
+ process.stderr.write(`Warning: could not fetch signal health (${msg})\n`);
57
+ return;
58
+ }
59
+ if (!signalRes.ok) {
60
+ process.stderr.write(`Warning: signal health fetch failed (HTTP ${signalRes.status})\n`);
61
+ return;
62
+ }
63
+ const signalJson = (await signalRes.json());
64
+ process.stdout.write('\n' + formatSignalHealth(signalJson) + '\n');
65
+ }
66
+ }
67
+ /** Deterministic thousands separator (no locale dependency, test-stable). */
68
+ function groupThousands(n) {
69
+ return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
70
+ }
71
+ const OUTCOME_ORDER = ['MERGED', 'REWORKED', 'REJECTED', 'UNKNOWN'];
72
+ /** "2 MERGED · 1 UNKNOWN", fixed order, zeros omitted. */
73
+ function outcomeSummary(counts) {
74
+ const parts = OUTCOME_ORDER.filter(o => counts[o] > 0).map(o => `${counts[o]} ${o}`);
75
+ return parts.join(' · ');
76
+ }
77
+ function fragmentOutcomeCounts(fragments) {
78
+ const c = {
79
+ MERGED: 0,
80
+ REJECTED: 0,
81
+ REWORKED: 0,
82
+ UNKNOWN: 0,
83
+ };
84
+ for (const f of fragments)
85
+ c[f.outcome] += 1;
86
+ return c;
87
+ }
88
+ function usageMarker(used) {
89
+ return used === true ? '✓' : used === false ? '✗' : '·';
90
+ }
91
+ /**
92
+ * Render a LineageResponse as the audit-facing markdown trail. Pure function
93
+ * (no clock / env) so the CLI output is deterministic and unit-testable.
94
+ */
95
+ function formatLineageMarkdown(data) {
96
+ const lines = [];
97
+ lines.push(`# Lineage: ${data.task.identifier} — ${(0, sanitize_1.sanitizeField)(data.task.title)}`);
98
+ lines.push(`**Status**: ${data.task.status}`);
99
+ lines.push('');
100
+ if (data.groups.length === 0) {
101
+ lines.push('_No lineage edges recorded yet. Lineage is captured when a ' +
102
+ "bound session consumes this task's context; once that happens " +
103
+ '(and a PR merges / the task closes), the causal trail and its cost ' +
104
+ 'will appear here._');
105
+ lines.push('');
106
+ return lines.join('\n');
107
+ }
108
+ const t = data.totals;
109
+ lines.push('## Totals');
110
+ lines.push(`- Runs/sessions: ${t.runCount}`);
111
+ lines.push(`- Fragments: ${t.fragmentCount}`);
112
+ lines.push(`- Edges: ${t.edgeCount}`);
113
+ lines.push(`- Tokens: ${groupThousands(t.tokens.total)} ` +
114
+ `(in ${groupThousands(t.tokens.input)} · out ${groupThousands(t.tokens.output)} · ` +
115
+ `cache-read ${groupThousands(t.tokens.cacheRead)} · cache-create ${groupThousands(t.tokens.cacheCreation)})`);
116
+ lines.push(`- Loops: ${t.loopCount}`);
117
+ const totalsOutcome = outcomeSummary(t.outcomes);
118
+ if (totalsOutcome)
119
+ lines.push(`- Outcomes: ${totalsOutcome}`);
120
+ lines.push('');
121
+ for (const g of data.groups) {
122
+ lines.push(`## ${(0, sanitize_1.sanitizeField)(g.label)} · ${g.includedAt.slice(0, 10)}`);
123
+ if (g.cost) {
124
+ lines.push(`**Cost**: ${groupThousands(g.cost.total)} tokens · ${g.cost.loopCount} loops`);
125
+ }
126
+ else {
127
+ lines.push('**Cost**: (no captured cost)');
128
+ }
129
+ const summary = outcomeSummary(fragmentOutcomeCounts(g.fragments));
130
+ lines.push(`**Fragments** (${g.fragments.length}${summary ? `: ${summary}` : ''}):`);
131
+ lines.push('_✓ used · · abstained · ✗ unused (manual)_');
132
+ for (const f of g.fragments) {
133
+ lines.push(`- ${usageMarker(f.used)} [${f.outcome}] ${f.fragmentType} — ${(0, sanitize_1.sanitizeField)(f.sourceLabel)}`);
134
+ }
135
+ lines.push('');
136
+ }
137
+ return lines.join('\n');
138
+ }
139
+ function formatSignalHealth(h) {
140
+ const lines = ['', '## Signal health'];
141
+ lines.push(`- Distribution: used ${h.distribution.used} · null ${h.distribution.abstained} · false ${h.distribution.unused}`);
142
+ lines.push(`- Per-session variance: ${h.perSessionVariance.toFixed(2)} (${h.votedSessions} voted sessions)`);
143
+ if (h.usedMergeRate !== null && h.baseMergeRate !== null) {
144
+ lines.push(`- Used × outcome: merge-rate(used) ${Math.round(h.usedMergeRate * 100)}% vs base ${Math.round(h.baseMergeRate * 100)}%`);
145
+ }
146
+ else {
147
+ lines.push('- Used × outcome: insufficient resolved tasks');
148
+ }
149
+ return lines.join('\n');
150
+ }
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FragmentUsageSection = void 0;
4
+ exports.parseUsedHandles = parseUsedHandles;
5
+ const sanitize_1 = require("../../lib/sanitize");
6
+ const fragment_usage_api_1 = require("../../lib/fragment-usage-api");
7
+ /** Parse "1,3 5" → 0-based indices. "none" → []. */
8
+ function parseUsedHandles(spec) {
9
+ if (spec.trim().toLowerCase() === 'none')
10
+ return [];
11
+ return spec
12
+ .split(/[\s,]+/)
13
+ .map(s => s.trim())
14
+ .filter(Boolean)
15
+ .map(s => parseInt(s, 10) - 1)
16
+ .filter(n => Number.isInteger(n) && n >= 0);
17
+ }
18
+ /**
19
+ * Wrap-panel section (LUM-300) that lists the context fragments this session
20
+ * consumed and records the agent's vote on which it actually used. Voting is
21
+ * non-interactive: the agent passes `lumo session wrap --used <indices>` (or
22
+ * `--used none`). Without `--used`, the section just lists candidates and writes
23
+ * nothing — edges stay null (honest "not voted"). Already-voted sessions skip.
24
+ */
25
+ class FragmentUsageSection {
26
+ deps;
27
+ title = '上下文使用投票';
28
+ draft = null;
29
+ constructor(deps) {
30
+ this.deps = deps;
31
+ }
32
+ async prepare() {
33
+ this.draft = await (0, fragment_usage_api_1.fetchUsageDraft)(this.deps.creds, this.deps.sessionId);
34
+ return this.draft.candidates.length > 0;
35
+ }
36
+ async run(opts) {
37
+ const draft = this.draft;
38
+ if (!draft || draft.candidates.length === 0)
39
+ return;
40
+ if (draft.alreadyVoted) {
41
+ process.stdout.write('本 session 已投票,跳过。\n');
42
+ return;
43
+ }
44
+ process.stdout.write('本次会话注入的 context fragment:\n');
45
+ draft.candidates.forEach((c, i) => {
46
+ process.stdout.write(` [${i + 1}] ${(0, sanitize_1.sanitizeField)(c.label)}\n`);
47
+ });
48
+ if (opts.dryRun) {
49
+ process.stdout.write('(dry-run,未改动)\n');
50
+ return;
51
+ }
52
+ if (this.deps.used === undefined) {
53
+ process.stdout.write('用 `lumo session wrap --used <序号>`(或 `--used none`)记录你实际用到的 fragment。\n');
54
+ return;
55
+ }
56
+ const idx = parseUsedHandles(this.deps.used);
57
+ const inRange = (n) => n >= 0 && n < draft.candidates.length;
58
+ const usedRefs = idx.filter(inRange).map(i => ({
59
+ fragmentType: draft.candidates[i].fragmentType,
60
+ fragmentId: draft.candidates[i].fragmentId,
61
+ }));
62
+ const { used, unused } = await (0, fragment_usage_api_1.applyFragmentUsage)(this.deps.creds, this.deps.sessionId, { usedRefs });
63
+ process.stdout.write(`已记录:用过 ${used} 个,未用 ${unused} 个。\n`);
64
+ }
65
+ }
66
+ exports.FragmentUsageSection = FragmentUsageSection;
@@ -73,6 +73,7 @@ const task_web_show_1 = require("./commands/task-web-show");
73
73
  const task_figma_context_1 = require("./commands/task-figma-context");
74
74
  const task_comment_list_1 = require("./commands/task-comment-list");
75
75
  const task_pr_show_1 = require("./commands/task-pr-show");
76
+ const task_lineage_1 = require("./commands/task-lineage");
76
77
  const project_list_1 = require("./commands/project-list");
77
78
  const milestone_list_1 = require("./commands/milestone-list");
78
79
  const milestone_create_1 = require("./commands/milestone-create");
@@ -218,6 +219,7 @@ session
218
219
  .description("Session-end wrap-up: draft a progress comment from this session's turn summaries and post it to the bound task after confirmation.")
219
220
  .option('-y, --yes', 'Post the drafted comment without prompting (agent-friendly)')
220
221
  .option('--dry-run', 'Print the draft but do not post or advance the watermark')
222
+ .option('--used <indices>', 'Mark which injected context fragments you actually used (1-based indices, comma/space separated; "none" for all-unused). Omit to skip recording.')
221
223
  .action(wrap(options => (0, session_wrap_1.sessionWrap)(options)));
222
224
  const task = program
223
225
  .command('task')
@@ -308,6 +310,11 @@ taskPr
308
310
  .command('show <identifier> <number>')
309
311
  .description('Show the synced PR record (diff/review comments when available)')
310
312
  .action(wrap((id, num) => (0, task_pr_show_1.taskPrShow)(id, num)));
313
+ task
314
+ .command('lineage <identifier>')
315
+ .description("Show the causal trail: which context fragments fed this task, each fragment's outcome, and the token/loop cost of the run that consumed it")
316
+ .option('--signal', 'Append usage-signal health section (fetches /api/lineage/signal)')
317
+ .action(wrap((id, options) => (0, task_lineage_1.taskLineage)(id, options)));
311
318
  const taskMemory = task
312
319
  .command('memory')
313
320
  .description('View and record memories scoped to a task');
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchUsageDraft = fetchUsageDraft;
4
+ exports.applyFragmentUsage = applyFragmentUsage;
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 fragment-usage draft (this session's injected fragments). */
10
+ async function fetchUsageDraft(creds, sessionId) {
11
+ const url = `${base(creds)}/api/sessions/${encodeURIComponent(sessionId)}/fragment-usage`;
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(`usage draft fetch failed (HTTP ${res.status})`);
19
+ return (await res.json());
20
+ }
21
+ /** POST the usage vote (used refs). Throws the server message on non-2xx. */
22
+ async function applyFragmentUsage(creds, sessionId, payload) {
23
+ const url = `${base(creds)}/api/sessions/${encodeURIComponent(sessionId)}/fragment-usage`;
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 ?? `usage vote failed (HTTP ${res.status})`);
45
+ }
46
+ return (await res.json());
47
+ }
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatHookStdoutLines = formatHookStdoutLines;
4
- exports.formatAutoBindLine = formatAutoBindLine;
4
+ exports.formatSuggestLine = formatSuggestLine;
5
5
  exports.resolveSessionStartStdout = resolveSessionStartStdout;
6
6
  exports.augmentBodyWithUsage = augmentBodyWithUsage;
7
7
  exports.runHook = runHook;
@@ -178,22 +178,26 @@ function sessionContextEnvelope(parts) {
178
178
  });
179
179
  }
180
180
  /**
181
- * The single status line printed after a successful auto-bind. Explains which
182
- * git signal (branch name vs recent commit) it relied on and how to undo it.
181
+ * The single line printed when a task is inferred from local git at session
182
+ * start. We deliberately do NOT bind we point the user at the explicit
183
+ * `lumo session attach` so binding stays a confirmed, manual action. The basis
184
+ * (branch name vs recent commit) is shown so the inference is auditable. No
185
+ * task title is available here because nothing was fetched; the title and
186
+ * context surface only once the user actually attaches.
183
187
  */
184
- function formatAutoBindLine(sessionId, match, result) {
188
+ function formatSuggestLine(sessionId, match) {
185
189
  const basis = match.source === 'branch' ? '分支名' : '最近 commit';
186
- const identifier = result.taskIdentifier ?? match.identifier;
187
- return `[Lumo] session_id=${sessionId} | 已自动绑定 ${identifier} - ${(0, sanitize_1.sanitizeField)(result.taskTitle ?? '')}(依据${basis})。如果不对,回复"不是"我就帮你解绑。`;
190
+ return `[Lumo] session_id=${sessionId} | 检测到 ${match.identifier}(依据${basis})。运行 lumo session attach ${match.identifier} 绑定。`;
188
191
  }
189
192
  /**
190
- * Build the stdout lines for a session-start response, including the LUM-145
191
- * auto-bind behavior: when the server reports no binding, infer the task from
192
- * local git and bind it; on a hit, print the auto-bind line (plus the freshly
193
- * bound task's memory). Falls back to the unbound prompt when nothing matches
194
- * or the bind fails. Bound sessions reuse the existing formatting.
193
+ * Build the stdout lines for a session-start response. When the server reports
194
+ * the session is already bound, reuse the bound formatting (status line +
195
+ * injected context). When it's unbound, infer the task from local git and — on
196
+ * a hit print a one-line *suggestion* to run `lumo session attach` (LUM-302:
197
+ * detect-and-suggest, never auto-bind). No match falls back to the generic
198
+ * unbound prompt.
195
199
  */
196
- async function resolveSessionStartStdout(responseBody, deps, now = new Date()) {
200
+ function resolveSessionStartStdout(responseBody, deps, now = new Date()) {
197
201
  if (responseBody == null || typeof responseBody !== 'object')
198
202
  return [];
199
203
  const body = responseBody;
@@ -209,58 +213,7 @@ async function resolveSessionStartStdout(responseBody, deps, now = new Date()) {
209
213
  const match = deps.extractTask();
210
214
  if (!match)
211
215
  return [unboundPromptLine(sessionId)];
212
- const result = await deps.bindTask(sessionId, match.identifier);
213
- if (!result.ok)
214
- return [unboundPromptLine(sessionId)];
215
- const lines = [formatAutoBindLine(sessionId, match, result)];
216
- const card = renderRecoveryCard(result.previousSession, result.taskIdentifier ?? match.identifier, now);
217
- // bind-task only returns memory + previousSession (not reviewTodosSection) —
218
- // the auto-bind endpoint never built PR-review todos. Keep that scope here;
219
- // surfacing review todos on the auto-bind path is a separate change.
220
- const envelope = sessionContextEnvelope([card, result.memorySection]);
221
- if (envelope)
222
- lines.push(envelope);
223
- return lines;
224
- }
225
- /**
226
- * POST the inferred task binding to the same `bind-task` endpoint that
227
- * `lumo session attach` uses. Guarded by the same short timeout as the hook
228
- * POST so the extra round trip can never make session-start hang. Any
229
- * failure (404 unknown task, network, timeout, non-2xx) returns
230
- * `{ ok: false }`, which routes the caller back to the unbound prompt.
231
- */
232
- async function postBindTask(sessionId, identifier, token, apiUrl) {
233
- const controller = new AbortController();
234
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
235
- try {
236
- const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}/bind-task`;
237
- const res = await fetch(url, {
238
- method: 'POST',
239
- headers: {
240
- 'Content-Type': 'application/json',
241
- Authorization: `Bearer ${token}`,
242
- },
243
- body: JSON.stringify({ taskIdentifier: identifier }),
244
- signal: controller.signal,
245
- });
246
- if (!res.ok)
247
- return { ok: false };
248
- const body = (await res.json());
249
- return {
250
- ok: true,
251
- taskIdentifier: body.taskIdentifier,
252
- taskTitle: body.taskTitle,
253
- memorySection: body.memorySection,
254
- previousSession: body.previousSession ?? undefined,
255
- };
256
- }
257
- catch (err) {
258
- (0, hook_log_1.logHookError)('[session-start] auto-bind', err);
259
- return { ok: false };
260
- }
261
- finally {
262
- clearTimeout(timer);
263
- }
216
+ return [formatSuggestLine(sessionId, match)];
264
217
  }
265
218
  /**
266
219
  * For stop/stop-failure hooks, read the transcript named in the payload and
@@ -285,7 +238,10 @@ function augmentBodyWithUsage(path, body) {
285
238
  const usage = (0, transcript_usage_1.sumTranscriptUsage)(transcriptPath);
286
239
  if (!usage)
287
240
  return body;
288
- return JSON.stringify({ ...parsed, _lumo_usage: usage });
241
+ return JSON.stringify({
242
+ ...parsed,
243
+ _lumo_usage: { ...usage.total, byModel: usage.byModel },
244
+ });
289
245
  }
290
246
  /**
291
247
  * POST the hook body to /api/hooks/<path> with a short timeout. All errors
@@ -348,16 +304,15 @@ async function runHookWithBody(path, body, agentToken) {
348
304
  }
349
305
  else if (path === 'session-start' || path === 'pre-tool-use') {
350
306
  // Paths that turn the response body into stdout for Claude Code:
351
- // session-start → bind status + injected context (incl. LUM-145
352
- // auto-bind from local git when unbound)
307
+ // session-start → bind status + injected context, or (when unbound)
308
+ // a LUM-302 suggest line inferred from local git
353
309
  // pre-tool-use → parallel-edit collision warning (LUM-150 ③)
354
310
  // Only after a 2xx so a transient server failure emits nothing.
355
311
  try {
356
312
  const responseBody = await res.json();
357
313
  const lines = path === 'session-start'
358
- ? await resolveSessionStartStdout(responseBody, {
314
+ ? resolveSessionStartStdout(responseBody, {
359
315
  extractTask: () => (0, git_task_1.extractTaskFromGit)(),
360
- bindTask: (sessionId, identifier) => postBindTask(sessionId, identifier, creds.token, apiUrl),
361
316
  })
362
317
  : formatHookStdoutLines(path, responseBody);
363
318
  for (const line of lines) {
@@ -7,8 +7,10 @@ function asNumber(v) {
7
7
  }
8
8
  /**
9
9
  * Read a Claude Code transcript JSONL and sum `message.usage` across all
10
- * assistant entries -> cumulative session token totals. Best-effort: returns
11
- * null if the file can't be read or has no assistant usage. Never throws.
10
+ * assistant entries, grouped by `message.model` -> cumulative session totals.
11
+ * `<synthetic>` entries (local injections with no API cost) are skipped.
12
+ * Entries with no model string bucket under "unknown". Best-effort: returns
13
+ * null if the file can't be read or has no real assistant usage. Never throws.
12
14
  */
13
15
  function sumTranscriptUsage(transcriptPath) {
14
16
  let text;
@@ -18,6 +20,7 @@ function sumTranscriptUsage(transcriptPath) {
18
20
  catch {
19
21
  return null;
20
22
  }
23
+ const byModel = {};
21
24
  const total = {
22
25
  inputTokens: 0,
23
26
  outputTokens: 0,
@@ -44,11 +47,31 @@ function sumTranscriptUsage(transcriptPath) {
44
47
  const usage = o.message?.usage;
45
48
  if (!usage)
46
49
  continue;
50
+ const model = typeof o.message?.model === 'string' && o.message.model.length > 0
51
+ ? o.message.model
52
+ : 'unknown';
53
+ if (model === '<synthetic>')
54
+ continue;
47
55
  seen = true;
48
- total.inputTokens += asNumber(usage.input_tokens);
49
- total.outputTokens += asNumber(usage.output_tokens);
50
- total.cacheReadTokens += asNumber(usage.cache_read_input_tokens);
51
- total.cacheCreationTokens += asNumber(usage.cache_creation_input_tokens);
56
+ const input = asNumber(usage.input_tokens);
57
+ const output = asNumber(usage.output_tokens);
58
+ const cacheRead = asNumber(usage.cache_read_input_tokens);
59
+ const cacheCreation = asNumber(usage.cache_creation_input_tokens);
60
+ total.inputTokens += input;
61
+ total.outputTokens += output;
62
+ total.cacheReadTokens += cacheRead;
63
+ total.cacheCreationTokens += cacheCreation;
64
+ const m = byModel[model] ?? {
65
+ input: 0,
66
+ output: 0,
67
+ cacheRead: 0,
68
+ cacheCreation: 0,
69
+ };
70
+ m.input += input;
71
+ m.output += output;
72
+ m.cacheRead += cacheRead;
73
+ m.cacheCreation += cacheCreation;
74
+ byModel[model] = m;
52
75
  }
53
- return seen ? total : null;
76
+ return seen ? { total, byModel } : null;
54
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.19.0",
3
+ "version": "1.20.1",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",