@lumoai/cli 1.36.0 → 1.37.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.
@@ -27,6 +27,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
27
27
  | `task artifact*`, `task figma*` | [references/artifacts-figma.md](references/artifacts-figma.md) |
28
28
  | `task criteria set/list`, drafting the acceptance contract | [references/criteria.md](references/criteria.md) |
29
29
  | `verify`, `task status` — machine verification loop, claim-done flow, self-check/resume | [references/verify.md](references/verify.md) |
30
+ | `cost` — per-operation (per-tool) token cost read-out; `task lineage` Top-5 | [references/task-context.md](references/task-context.md) |
30
31
  | `project list`, `milestone*` | [references/milestones.md](references/milestones.md) |
31
32
  | `doc*` | [references/docs.md](references/docs.md) |
32
33
  | `sprint*` | [references/sprints.md](references/sprints.md) |
@@ -51,11 +52,11 @@ The command catalog below is a **map**: it lists every command grouped by domain
51
52
  - `lumo task figma context <id> <linkId>` — Figma link metadata (v1)
52
53
  - `lumo task comments list <id>` — comment thread, capped to the output budget (`--full` prints every comment; read-only; ≠ `task comment`)
53
54
  - `lumo task pr show <id> <number>` — synced PR metadata (v1)
54
- - `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 via iteration-taint fold — tasks with a send-back/reopen/PR-close count as the negative class even if later merged; shows negative-class size per side; prints "metric cannot discriminate" when no failure outcomes exist yet)
55
+ - `lumo task lineage <id>` — show the causal trail: fragments that fed the task + each one's outcome (each fragment line shows its disclosure tag: `· INDEX pulled` / `· INDEX not-pulled` / `· FULL`) + the run's token/loop cost (read-only audit view); totals include a single-task disclosure-funnel summary (`- Disclosure funnel: N impressions · M INDEX (X%) · K pulled (Y% of INDEX) · J used (Z%)`) and a per-task **"Top operations by token cost"** Top-5 — the most expensive tools by attributed token cost, with a pointer to the full breakdown via `lumo cost --task <id>`; `lumo task lineage <id> --signal` also appends workspace-level usage signal-health (used distribution, per-session variance, used-vs-base merge rate via iteration-taint fold — tasks with a send-back/reopen/PR-close count as the negative class even if later merged; shows negative-class size per side; prints "metric cannot discriminate" when no failure outcomes exist yet; also prints a raw count of mid-flight `--new-scope` spinoffs (recorded, not yet judged — LUM-511 Phase 3)) and a workspace-wide disclosure funnel in the same shape as the single-task line
55
56
 
56
57
  **Tasks** — see [tasks.md](references/tasks.md)
57
58
 
58
- - `lumo task create <title> [flags]` — create a task
59
+ - `lumo task create <title> [flags]` — create a task. **Mid-task** (your session is bound to an in-flight task) it requires `--rework-of <id>` (redirects you to fix the existing task — creates nothing) or `--new-scope` (genuinely new, out-of-scope work). On a send-back, fix in place / amend the contract instead of spinning off a new task — see [verify.md](references/verify.md) and [criteria.md](references/criteria.md).
59
60
  - `lumo task update <id> [flags]` — patch status/title/priority/assignee/milestone/sprint/tags
60
61
  - `lumo task list [flags]` — list tasks assigned to you
61
62
  - `lumo next [--count N]` — recommend the next task to work on (read-only)
@@ -79,8 +80,12 @@ The command catalog below is a **map**: it lists every command grouped by domain
79
80
  **Verification (machine acceptance loop)** — see [verify.md](references/verify.md)
80
81
 
81
82
  - `lumo verify [task] [--timeout <seconds>]` — run every MACHINE criterion's checkpointer locally, report one structured PASS/FAIL verdict per criterion to the server, print next actions. Defaults to the session-bound task. Round cap 3: an all-pass round moves the task to IN_REVIEW (agent stops there); a round-3 fail escalates to a human (stop retrying). **Run this before claiming a task is done.**
82
- - `lumo task status [task] [--json]` — read-only acceptance self-check (no LLM, milliseconds): the contract with each criterion's latest verdict (REVIEW_ADDED provenance visible), verification history, current round, last round's failure reasons, `nextActions` = the unmet criteria (the declarative "what's next" — no separate plan), and any OPEN (undispositioned) boundary crossings (count + per crossing category/severity/detail + a read-only attribution line `↳ by model=…·agent=…·session=…` naming who/what crossed, `unknown` when unresolved — LUM-469; `--json` adds an `openCrossings` field, each entry carrying an `attribution` object) — read-only awareness, disposition stays web + human-only (LUM-448). The crossings check fails closed (LUM-480): if the read errors, the block prints `⚠ Boundary-crossing check failed` instead of staying silent, and `--json` sets `openCrossings: null` (distinct from `[]` = a successful read with zero open — treat `null` as "could not confirm", not "safe"). Defaults to the session-bound task; `--json` emits a versioned payload (`version` field). **Run it first when resuming a task in a new session or after a verification round was rejected.**
83
- - `lumo verdict [task] --pass | --pass-with-followup | --fail` — acceptance verdicts (LUM-422). `--pass` / `--pass-with-followup` open the browser to the human verdict bar focused on the passing action (a deep link — **records nothing**; a passing data row is only ever a human's own click). `--fail --reason <enum> [--note <text>] [--criterion <id>…]` records an **AGENT send-back** (verifierType=AGENT, verdict hard-coded FAIL) and bounces the task to IN_PROGRESS. Defaults to the session-bound task. **An unresolved send-back (machine/AGENT/human FAIL) blocks the agent/CLI DONE transition with 409** — clear it (re-verify) before `task update --status done`.
83
+ - `lumo task status [task] [--json]` — read-only acceptance self-check (no LLM, milliseconds): the contract with each criterion's latest verdict (REVIEW_ADDED provenance visible), verification history, current round, last round's failure reasons, a per-criterion **send-back lifecycle** line when applicable (`↳ send-back (rN) resolved in rM · PR #K` / `… open` — LUM-511 Phase 5), `nextActions` = the unmet criteria (the declarative "what's next" — no separate plan), and any OPEN (undispositioned) boundary crossings (count + per crossing category/severity/detail + a read-only attribution line `↳ by model=…·agent=…·session=…` naming who/what crossed, `unknown` when unresolved — LUM-469; `--json` adds an `openCrossings` field, each entry carrying an `attribution` object) — read-only awareness, disposition stays web + human-only (LUM-448). The crossings check fails closed (LUM-480): if the read errors, the block prints `⚠ Boundary-crossing check failed` instead of staying silent, and `--json` sets `openCrossings: null` (distinct from `[]` = a successful read with zero open — treat `null` as "could not confirm", not "safe"). Defaults to the session-bound task; `--json` emits a versioned payload (`version` field). **Run it first when resuming a task in a new session or after a verification round was rejected.**
84
+ - `lumo verdict [task] --pass | --fail` — acceptance verdicts (LUM-422). `--pass` opens the browser to the human verdict bar focused on Pass (a deep link — **records nothing**; a passing data row is only ever a human's own click). `--fail --reason <enum> [--note <text>] [--criterion <id>…]` records an **AGENT send-back** (verifierType=AGENT, verdict hard-coded FAIL) and bounces the task to IN_PROGRESS. Defaults to the session-bound task. **An unresolved send-back (machine/AGENT/human FAIL) blocks the agent/CLI DONE transition with 409** — clear it (re-verify) before `task update --status done`.
85
+
86
+ **Cost (per-operation token read-out)** — see [task-context.md](references/task-context.md)
87
+
88
+ - `lumo cost [--task <id>|--session <id>|--since <date>] [--by tool|model|member|session] [--json]` — per-operation (per-tool) token cost read-out. Attributes each model step's token delta to the tool(s) it ran (per-step where POST_TOOL_BATCH data exists, per-turn fallback otherwise), output vs cache_read shown separately, plus a per-step coverage line and a "heuristic" note (parallel tools split a step's tokens evenly). Scope is mutually exclusive: `--task` (one task) / `--session` (one Claude Code session) / `--since` (workspace window); default = workspace last-30-days. `--by` only changes which grouping is the headline (the others are still printed when non-trivial). For the per-task Top-5 inline, see `lumo task lineage`.
84
89
 
85
90
  **Artifacts & Figma** — see [artifacts-figma.md](references/artifacts-figma.md)
86
91
 
@@ -120,6 +125,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
120
125
  **Memory** — see [memory.md](references/memory.md)
121
126
 
122
127
  - `lumo task memory add/list` · `lumo project memory add/list` — record/curate Memory (TASK vs PROJECT)
128
+ - `lumo memory show <id>` — show one memory's full card (category + content) by id (progressive disclosure from a one-line index entry)
123
129
  - `lumo memory promote <id>` / `lumo memory rm <id> --yes` — TASK→PROJECT / delete
124
130
 
125
131
  **Sessions** — see [sessions.md](references/sessions.md)
@@ -49,6 +49,19 @@ across all stages: a criterion that already has verification runs is
49
49
  run-free criterion is hard-deleted. Either way, reword rather than delete when
50
50
  possible.
51
51
 
52
+ ### A send-back may mean the contract was wrong — amend it, don't spin off
53
+
54
+ If a send-back reveals the **contract itself** was wrong or incomplete, the right
55
+ move is to **amend the criteria on this task** (`lumo task criteria set <id>`,
56
+ and with `--human` annotate the reason via `--cause NEW_INFO | SCOPE_CHANGE |
57
+ DRAFT_BLIND_SPOT`) and re-verify — **not** to open a new task. Sharpening the
58
+ contract as understanding grows is expected; that's what the drift trail is
59
+ _for_, not something to avoid. **The line:** amending to reflect reality is
60
+ legitimate; **weakening a criterion you were just FAILed on, purely to make it
61
+ pass, is tampering** — it's audited and counts as a failure either way. Deleting
62
+ a failed criterion doesn't help: the DONE gate still blocks on its standing FAIL
63
+ (the run is soft-deleted, the verdict survives).
64
+
52
65
  ## Scale the contract to the task size
53
66
 
54
67
  The 3–7 range is calibrated for typical multi-file tasks. The criterion count
@@ -82,7 +82,7 @@ The `Tags:` line is omitted when no tags were attached.
82
82
  | `--title <text>` | string | New title (cannot be empty). |
83
83
  | `--content <text>` | string | Replace content (inline). |
84
84
  | `--file <path>` | string | Replace content from file. |
85
- | (stdin) | — | Pipe to replace content. |
85
+ | (stdin) | — | Pipe to replace content. Empty / whitespace-only stdin (a non-TTY shell with nothing piped — the common agent case) is treated as **no content channel**, not a body clear (LUM-505). |
86
86
  | `--scope <scope>` | enum | `personal` / `workspace`. |
87
87
  | `--project <ref>` | string | Project name/slug. `--project ""` clears the filing. |
88
88
  | `--tag <name>` | string (repeatable) | **Bulk replace** the tag set by name. Creates tag if missing. Max 20. Mutually exclusive with `--add-tag*` / `--remove-tag*`. |
@@ -93,6 +93,8 @@ The `Tags:` line is omitted when no tags were attached.
93
93
  | `--remove-tag-id <cuid>` | string (repeatable) | Detach tag by id. Unknown ids are a no-op (no side effects). Max 20. |
94
94
  | `--allow-shrink` | boolean | Let a body update through even when it drops tables/rows/headings versus the stored body (see structure guard below). |
95
95
 
96
+ A **metadata-only update leaves the body untouched** (LUM-505): when no content channel is supplied (`--title`/`--scope`/`--project`/tag flags only), the body is omitted from the PATCH — it does not get blanked and the structure guard cannot fire. To deliberately clear or replace the body you must supply a content channel explicitly (`--content ""` to clear, which then hits the structure guard as a shrink → pair with `--allow-shrink`).
97
+
96
98
  Optimistic concurrency (LUM-409): `--if-revision <n>` only applies the update if the doc body is still at revision `n` (from `doc show`). Mismatch → 409 conflict, nothing written — re-read, rebase, retry. `--if-revision` alone is not an update (still errors "no fields to update"); same for `--allow-shrink`.
97
99
 
98
100
  **Structure guard (LUM-410), built into the server:** a body update whose new render has **fewer `table` / `tr` / heading elements than the stored body** is rejected with **422** before anything is written — the error names each shrunk category with old→new counts (e.g. `table 1→0, tr 4→0`). This is the `verify-live-doc.ts` reconciliation moved into the write path, so table flattening (LUM-349) and stale-base section loss (#460) fail loudly even when nobody remembers to run the script. When the deletion is intentional (you really are removing a section/table), re-run with `--allow-shrink`. On a 422: don't reach for `--allow-shrink` reflexively — first check whether your edit base is stale (`doc show <doc> --raw`) and rebase. Only markdown-path writes are guarded; web-editor edits and `doc sync` (Google authority) are not.
@@ -26,6 +26,7 @@ lumo project memory add [<project>] --category convention --rule "..." --applies
26
26
  # Omitting --agent records the memory as produced by Claude Code.
27
27
 
28
28
  # Single-memory ops (memoryId from `... memory list` column 1)
29
+ lumo memory show <memoryId> # show one memory's full card by id
29
30
  lumo memory promote <memoryId> # TASK → PROJECT
30
31
  lumo memory rm <memoryId> --yes # hard delete
31
32
  ```
@@ -47,10 +48,29 @@ lumo task memory add LUM-42 --category decision --what "Store doc content as Tip
47
48
  lumo project memory add lumo --category procedural --workflow "Regenerate the CLI grammar snapshot" --trigger "any cli/src/commands change" --step "npx tsx scripts/analysis/lum392-cli-friction/emit-grammar.ts" --step "npx jest scripts/analysis/lum392-cli-friction" --agent claude-code
48
49
 
49
50
  # Curate
51
+ lumo memory show cmpi19iqabc123
50
52
  lumo memory promote cmpi19iqabc123
51
53
  lumo memory rm cmpi19iqabc123 --yes
52
54
  ```
53
55
 
56
+ ### `lumo memory show <id>` (progressive disclosure)
57
+
58
+ Fetches one memory's full card by id from the server and prints its category tag
59
+ (`[TRAP]`, `[DECISION]`, `[CONVENTION]`, `[WORKFLOW]`, …) followed by the
60
+ structured `content` body (pretty-printed JSON).
61
+
62
+ - **Arg**: `<memoryId>` (required) — the id you saw in `... memory list` column 1
63
+ or as a one-line index entry at session start. Without it the command errors
64
+ with a usage line and exits 1.
65
+ - **Errors**: not-logged-in (run `lumo auth login`), `401` invalid/revoked key,
66
+ `404` memory not found in your workspace (cross-workspace ids 404 too), other
67
+ non-2xx → generic HTTP error; all exit 1.
68
+
69
+ **When to suggest**: at session start memories arrive as one-line index entries.
70
+ When a specific entry looks relevant to the task at hand, run
71
+ `lumo memory show <id>` to pull its full body before acting on it — read the
72
+ detail on demand instead of carrying every memory's content in context.
73
+
54
74
  ### Reconcile-on-write & deduplication
55
75
 
56
76
  `memory add` does **not** unconditionally insert a new row. Before writing it:
@@ -137,15 +137,33 @@ lumo task lineage LUM-42 --signal # append workspace-level usage signal-health
137
137
 
138
138
  - **Totals banner** — distinct sessions, fragment count, edge count,
139
139
  total tokens (input/output/cache split) and loops, and the outcome
140
- distribution.
140
+ distribution. After the outcome summary, one funnel line is appended:
141
+ `- Disclosure funnel: N impressions · M INDEX (X%) · K pulled (Y% of INDEX) · J used (Z%)`
142
+ where impressions = edge count, INDEX% and used% are over impressions,
143
+ pull% is over INDEX only (FULL fragments have no pull opportunity).
144
+ Divide-by-zero is guarded (zero impressions or zero INDEX renders `0%`).
145
+ When per-fragment token weights have been collected (LUM-522), the line also
146
+ appends `· ~T tokens saved` = Σ(fullTokens − indexTokens) over un-pulled INDEX
147
+ edges (the token cost the index-only injection avoided); the suffix is omitted
148
+ cleanly when no edge carries token data yet (older edges predate the columns).
141
149
  - **One block per session** — the group's cost shown **once** (token/loop),
142
150
  the date it consumed context, then each context fragment as
143
- `[OUTCOME] TYPE — <source label>`, plus a per-group outcome summary.
151
+ `[OUTCOME] TYPE — <source label>`, plus a disclosure annotation suffix:
152
+ `· INDEX pulled` (INDEX fragment, `pulledAt` is set) /
153
+ `· INDEX not-pulled` (INDEX fragment, never pulled) /
154
+ `· FULL` (injected in full at session-start).
155
+ Per-group outcome summary follows.
144
156
 
145
157
  Cost is attributed once per session (a session that injected many fragments is
146
158
  not double-counted). Fragment ids are canonical — MEMORY fragments survive
147
159
  consolidation drift.
148
160
 
161
+ **`--signal` workspace funnel:** the workspace-level usage signal-health block
162
+ appended by `--signal` ends with a workspace-wide disclosure funnel in the
163
+ same format: `- Disclosure funnel: N impressions · M INDEX (X%) · K pulled (Y% of INDEX) · J used (Z%)`
164
+ aggregated over all edges in the workspace (not just this task) — including the
165
+ same `· ~T tokens saved` suffix when token data exists (LUM-522).
166
+
149
167
  **Cold start:** a task with no edges prints a friendly note (lineage is captured
150
168
  when a session-bound run consumes the task's context), not an error.
151
169
 
@@ -154,3 +172,48 @@ use, and what did it cost" for a task / merged PR — CFO / compliance / trust
154
172
  narratives.
155
173
 
156
174
  Entry point is the task identifier only; PR-number lookup is a future addition.
175
+
176
+ **Top operations by token cost (LUM-523):** the lineage totals also append a
177
+ per-task **"Top operations by token cost"** Top-5 — the most expensive tools by
178
+ attributed token cost (`<tool> — N tokens`), ending with the pointer
179
+ `(full breakdown: lumo cost --task <id>)`. The block is omitted when no
180
+ per-operation cost has been attributed yet.
181
+
182
+ ## `lumo cost`
183
+
184
+ Per-operation (per-tool) token cost read-out. Where `task lineage` answers
185
+ "what context fed this task and what did the run cost," `lumo cost` answers
186
+ "which _operations_ (tools) burned the tokens." It attributes each model step's
187
+ token delta to the tool(s) that ran in it — **per-step** where the
188
+ `POST_TOOL_BATCH` hook captured the tool list, **per-turn fallback** otherwise
189
+ (a parallel-tool step splits its tokens evenly across the tools, hence the
190
+ "heuristic" note). `output` (generation) and `cache_read` (~95%, structural —
191
+ turns × context) are shown in separate columns.
192
+
193
+ ```bash
194
+ lumo cost --task LUM-42
195
+ lumo cost --session <session-id> --by model
196
+ lumo cost --since 2026-06-01 --by member --json
197
+ ```
198
+
199
+ - **Scope (mutually exclusive)** — `--task <id>` scopes to one task,
200
+ `--session <id>` to one Claude Code session, `--since <ISO-date>` to a
201
+ workspace window from that date. With none given, the default is a
202
+ **workspace last-30-days** window. (If more than one is passed, the CLI
203
+ picks task > session > since.)
204
+ - **`--by tool|model|member|session`** (default `tool`) — only changes which
205
+ grouping is the **headline** table; the other groupings are still printed
206
+ below when non-trivial (member/session tables appear only when there is more
207
+ than one). Case-insensitive.
208
+ - **`--json`** — emit the versioned payload (`version: 1`, scope, grandTotal,
209
+ coverage, and `byTool` / `byModel` / `byMember` / `bySession` row arrays)
210
+ instead of the rendered tables.
211
+ - **Coverage line** — `Per-step attribution: X% of N tool-using turns` tells
212
+ you how much of the report is precise per-step attribution vs the per-turn
213
+ fallback. `n/a` when there were no tool-using turns.
214
+
215
+ **When to suggest:** the user asks where their tokens went _by operation_ —
216
+ "which tools are most expensive," cost attribution by model / teammate /
217
+ session, or a workspace cost window. For the quick per-task Top-5 inline, point
218
+ them at `lumo task lineage <id>` instead; reach for `lumo cost` for the full
219
+ breakdown or any non-task scope.
@@ -217,17 +217,17 @@ is ever agent-produced.**
217
217
 
218
218
  ```
219
219
  lumo verdict --pass
220
- lumo verdict LUM-42 --pass-with-followup
220
+ lumo verdict LUM-42 --pass
221
221
  lumo verdict --fail --reason CRITERION_UNMET --note "the retry path is still missing"
222
222
  lumo verdict LUM-42 --fail --reason scope_mismatch --criterion c-abc123
223
223
  ```
224
224
 
225
- ### --pass / --pass-with-followup — a deep link, never a write
225
+ ### --pass — a deep link, never a write
226
226
 
227
- These resolve the task, then open the browser to its verdict bar focused on the
228
- passing action. **The CLI writes nothing** — PASS / PASS_WITH_FOLLOWUP only ever
229
- land from a human's own click (Clerk session). Use this to hand a finished task
230
- to a human for the final pass; it carries them one click from recording it.
227
+ This resolves the task, then opens the browser to its verdict bar focused on
228
+ Pass. **The CLI writes nothing** — PASS only ever lands from a human's own click
229
+ (Clerk session). Use this to hand a finished task to a human for the final pass;
230
+ it carries them one click from recording it.
231
231
 
232
232
  ### --fail — the AGENT send-back
233
233
 
@@ -256,3 +256,42 @@ criteria were never adjudicated, transitions freely — the gate only blocks an
256
256
  actual send-back, never an un-adjudicated criterion. When the machine loop has
257
257
  left a task IN_REVIEW with no send-back standing, the agent may move it to DONE
258
258
  directly; a human-PASS row is a provable manual override, not a required ticket.
259
+
260
+ ## When a defect appears — fix in place, don't spin off a new task
261
+
262
+ On a send-back **or** a self-review finding: if the issue falls under any
263
+ existing acceptance criterion of **this** task, fix it in place and re-run
264
+ `lumo verify`. **Do not** `lumo task create` for it. New tasks are only for work
265
+ genuinely _outside_ this task's acceptance contract. Creating a task (and PR)
266
+ for in-scope rework launders a first-attempt failure — it bypasses the DONE
267
+ gate's send-back protection and corrupts the flywheel signal.
268
+
269
+ This is now enforced: when you're mid-task, `lumo task create` refuses the bare
270
+ form and makes you declare intent — `--rework-of <id>` (it redirects you back to
271
+ fix the existing task and creates nothing) or `--new-scope` (genuinely new,
272
+ separate work). If the send-back reveals the **contract itself** was wrong,
273
+ amend it on this task (`lumo task criteria set`) rather than opening a new
274
+ task — see criteria.md.
275
+
276
+ **Hard rule:** while THIS task has an unresolved send-back (any criterion's
277
+ latest verdict is FAIL — the same condition that blocks DONE), `lumo task create`
278
+ is refused with 409 **even with `--new-scope`**. A standing send-back means the
279
+ task can't be completed yet; resolve it (fix + `lumo verify`, or amend the
280
+ contract) before opening any new work. `--rework-of` still redirects you to it.
281
+
282
+ ## Human-reported defects, once a task is submitted
283
+
284
+ When someone reports a defect in conversation, your action depends on whether the
285
+ task has **ever entered IN_REVIEW**:
286
+
287
+ - **Not yet** (still your first working pass) → just fix it and continue. No
288
+ verdict needed — nothing was claimed complete, so there's nothing to contradict.
289
+ - **Already submitted** (entered IN*REVIEW / DONE / merged) → **do not silently
290
+ fix and re-pass.** Either record your own send-back (`lumo verdict --fail`,
291
+ noting it was human-reported — this is \_your* honest concurrence, not a forged
292
+ human verdict), or ask the reporter to record a human FAIL via the web UI /
293
+ Slack (the only channel that can attribute it to a human). If the defect is a
294
+ **new requirement** not covered by any criterion, first transcribe it with
295
+ `lumo task criteria set --human`, then proceed. You can never write a human
296
+ _verdict_ — the terminal can't prove a human is behind the command
297
+ (attribution integrity, not anti-forgery).
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatOperationCost = formatOperationCost;
4
+ exports.cost = cost;
5
+ const config_1 = require("../lib/config");
6
+ const api_1 = require("../lib/api");
7
+ /** Deterministic thousands separator (no locale dependency, test-stable). */
8
+ function groupThousands(value) {
9
+ return Math.round(value)
10
+ .toString()
11
+ .replace(/\B(?=(\d{3})+(?!\d))/g, ',');
12
+ }
13
+ function rankTable(title, rows) {
14
+ if (rows.length === 0)
15
+ return '';
16
+ const lines = [
17
+ title,
18
+ ' operation output cache_read total',
19
+ ];
20
+ for (const r of rows.slice(0, 20)) {
21
+ lines.push(` ${r.label.slice(0, 20).padEnd(20)} ${groupThousands(r.output).padStart(10)} ${groupThousands(r.cacheRead).padStart(12)} ${groupThousands(r.total).padStart(12)}`);
22
+ }
23
+ return lines.join('\n');
24
+ }
25
+ /**
26
+ * Render an OperationCostResponse as the per-operation cost report. Pure
27
+ * function (no clock / env / network) so it is deterministic and unit-testable.
28
+ */
29
+ function formatOperationCost(data, by) {
30
+ const primary = by === 'model'
31
+ ? data.byModel
32
+ : by === 'member'
33
+ ? data.byMember
34
+ : by === 'session'
35
+ ? data.bySession
36
+ : data.byTool;
37
+ const pct = data.coverage.perStepPct == null
38
+ ? 'n/a'
39
+ : `${Math.round(data.coverage.perStepPct * 100)}%`;
40
+ const out = [];
41
+ out.push(`Per-operation cost — ${data.scope.kind} ${data.scope.label}`);
42
+ out.push(`Total: output ${groupThousands(data.grandTotal.output)} · cache_read ${groupThousands(data.grandTotal.cacheRead)} · all ${groupThousands(data.grandTotal.total)} tokens`);
43
+ out.push(`Per-step attribution: ${pct} of ${data.coverage.toolTurns} tool-using turns (rest fall back to per-turn split)`);
44
+ out.push('');
45
+ out.push(rankTable(`By ${by}:`, primary));
46
+ if (by !== 'tool')
47
+ out.push('\n' + rankTable('By tool:', data.byTool));
48
+ if (by !== 'model')
49
+ out.push('\n' + rankTable('By model:', data.byModel));
50
+ if (by !== 'member' && data.byMember.length > 1)
51
+ out.push('\n' + rankTable('By member:', data.byMember));
52
+ if (by !== 'session' && data.bySession.length > 1)
53
+ out.push('\n' + rankTable('By session:', data.bySession));
54
+ out.push('');
55
+ out.push('Note: token→tool attribution is heuristic — a model step that fires several tools in parallel splits its tokens evenly across them; cache_read (~95%) is structural (turns × context), shown alongside output (generation).');
56
+ return out.join('\n');
57
+ }
58
+ async function cost(opts) {
59
+ const creds = (0, config_1.readCredentials)();
60
+ if (!creds) {
61
+ console.error('Error: not logged in. Run `lumo auth login` first.');
62
+ return 1;
63
+ }
64
+ const byArg = opts.by?.toLowerCase();
65
+ const by = byArg === 'model' || byArg === 'member' || byArg === 'session'
66
+ ? byArg
67
+ : 'tool';
68
+ const params = new URLSearchParams();
69
+ if (opts.task)
70
+ params.set('task', opts.task);
71
+ else if (opts.session)
72
+ params.set('session', opts.session);
73
+ else if (opts.since)
74
+ params.set('since', opts.since);
75
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
76
+ const qs = params.toString();
77
+ const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/cost${qs ? `?${qs}` : ''}`;
78
+ let res;
79
+ try {
80
+ res = await fetch(url, {
81
+ headers: { Authorization: `Bearer ${creds.token}` },
82
+ });
83
+ }
84
+ catch (err) {
85
+ const msg = err instanceof Error ? err.message : String(err);
86
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
87
+ return 1;
88
+ }
89
+ if (res.status === 401) {
90
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
91
+ return 1;
92
+ }
93
+ if (res.status === 404) {
94
+ console.error(`Error: task ${opts.task ?? ''} not found in workspace ${creds.workspaceSlug}`);
95
+ return 1;
96
+ }
97
+ if (!res.ok) {
98
+ console.error(`Error: server returned HTTP ${res.status}`);
99
+ return 1;
100
+ }
101
+ const data = (await res.json());
102
+ if (opts.json) {
103
+ console.log(JSON.stringify(data, null, 2));
104
+ return;
105
+ }
106
+ console.log(formatOperationCost(data, by));
107
+ }
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.memoryShow = memoryShow;
4
+ const config_1 = require("../lib/config");
5
+ const api_1 = require("../lib/api");
6
+ const sanitize_1 = require("../lib/sanitize");
7
+ const report_pull_1 = require("../lib/report-pull");
8
+ /**
9
+ * `lumo memory show <memory-id>`
10
+ *
11
+ * Progressive disclosure: fetch one memory's full card by id from
12
+ * `/api/memories/:id`. The agent sees memories as one-line index entries at
13
+ * session start; this pulls the body of a specific one on demand. Prints the
14
+ * category tag and the structured content.
15
+ */
16
+ async function memoryShow(id) {
17
+ if (!id) {
18
+ console.error('Error: usage: lumo memory show <memory-id>');
19
+ return 1;
20
+ }
21
+ const creds = (0, config_1.readCredentials)();
22
+ if (!creds) {
23
+ console.error('Error: not logged in. Run `lumo auth login` first.');
24
+ return 1;
25
+ }
26
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
27
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
28
+ let res;
29
+ try {
30
+ res = await fetch(`${base}/api/memories/${encodeURIComponent(id)}`, {
31
+ headers: { Authorization: `Bearer ${creds.token}` },
32
+ });
33
+ }
34
+ catch (err) {
35
+ const msg = err instanceof Error ? err.message : String(err);
36
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
37
+ return 1;
38
+ }
39
+ if (res.status === 401) {
40
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
41
+ return 1;
42
+ }
43
+ if (res.status === 404) {
44
+ console.error(`Error: memory ${id} not found in workspace ${creds.workspaceSlug}`);
45
+ return 1;
46
+ }
47
+ if (!res.ok) {
48
+ console.error(`Error: memory show failed (HTTP ${res.status})`);
49
+ return 1;
50
+ }
51
+ const { memory } = (await res.json());
52
+ console.log((0, sanitize_1.sanitizeField)(`[${memory.category}]`));
53
+ console.log((0, sanitize_1.sanitizeField)(JSON.stringify(memory.content, null, 2)));
54
+ // LUM-500: stamp the disclosure funnel. The arg id == lineage MEMORY
55
+ // fragmentId. Fire-and-forget — never blocks output, swallows failures.
56
+ await (0, report_pull_1.reportPull)({ fragmentType: 'MEMORY', fragmentId: id });
57
+ }
@@ -38,7 +38,12 @@ async function bindTaskToSprint(base, token, workspaceSlug, sprintRef, task) {
38
38
  throw new Error(`sprint lookup failed (HTTP ${sprintRes.status})`);
39
39
  }
40
40
  const { sprint: full } = (await sprintRes.json());
41
- sprint = { id: full.id, number: full.number, name: full.name, teamId: full.teamId };
41
+ sprint = {
42
+ id: full.id,
43
+ number: full.number,
44
+ name: full.name,
45
+ teamId: full.teamId,
46
+ };
42
47
  }
43
48
  assertSameTeam(task, sprint);
44
49
  const bindRes = await fetch(`${base}/api/sprints/${sprint.id}/tasks`, {
@@ -137,14 +142,22 @@ async function taskCreate(title, opts) {
137
142
  body.milestoneRef = opts.milestone;
138
143
  if (tagIds && tagIds.length > 0)
139
144
  body.tagIds = tagIds;
145
+ if (opts.reworkOf !== undefined)
146
+ body.reworkOfRef = opts.reworkOf;
147
+ if (opts.newScope)
148
+ body.newScope = true;
149
+ const headers = {
150
+ Authorization: `Bearer ${creds.token}`,
151
+ 'Content-Type': 'application/json',
152
+ };
153
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
154
+ if (sessionId)
155
+ headers['X-Lumo-Session-Id'] = sessionId;
140
156
  let res;
141
157
  try {
142
158
  res = await fetch(url, {
143
159
  method: 'POST',
144
- headers: {
145
- Authorization: `Bearer ${creds.token}`,
146
- 'Content-Type': 'application/json',
147
- },
160
+ headers,
148
161
  body: JSON.stringify(body),
149
162
  });
150
163
  }
@@ -165,7 +178,11 @@ async function taskCreate(title, opts) {
165
178
  if (opts.sprint) {
166
179
  const workspaceSlug = creds.workspaceSlug ?? '';
167
180
  try {
168
- const sprint = await bindTaskToSprint(base, creds.token, workspaceSlug, opts.sprint, { id: data.task.id, teamId: data.task.teamId, identifier: data.task.identifier });
181
+ const sprint = await bindTaskToSprint(base, creds.token, workspaceSlug, opts.sprint, {
182
+ id: data.task.id,
183
+ teamId: data.task.teamId,
184
+ identifier: data.task.identifier,
185
+ });
169
186
  process.stdout.write(`Sprint: #${sprint.number} "${(0, sanitize_1.sanitizeField)(sprint.name)}"\n`);
170
187
  }
171
188
  catch (err) {
@@ -4,6 +4,7 @@ exports.taskFigmaContext = taskFigmaContext;
4
4
  const config_1 = require("../lib/config");
5
5
  const api_1 = require("../lib/api");
6
6
  const sanitize_1 = require("../lib/sanitize");
7
+ const report_pull_1 = require("../lib/report-pull");
7
8
  /**
8
9
  * `lumo task figma context <LUM-N> <link-id>`
9
10
  *
@@ -58,4 +59,7 @@ async function taskFigmaContext(identifier, linkId) {
58
59
  console.log(`syncError: ${(0, sanitize_1.sanitizeField)(metadata.lastSyncError)}`);
59
60
  if (note)
60
61
  console.log(`\nnote: ${(0, sanitize_1.sanitizeField)(note)}`);
62
+ // LUM-500: stamp the disclosure funnel. The linkId arg == lineage FIGMA
63
+ // fragmentId. Fire-and-forget — never blocks output, swallows failures.
64
+ await (0, report_pull_1.reportPull)({ fragmentType: 'FIGMA', fragmentId: linkId });
61
65
  }
@@ -68,6 +68,10 @@ async function taskLineage(identifier, opts) {
68
68
  function groupThousands(n) {
69
69
  return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
70
70
  }
71
+ /** Integer percentage, 0 when the denominator is 0 (never NaN). */
72
+ function pct(num, den) {
73
+ return den === 0 ? 0 : Math.round((num / den) * 100);
74
+ }
71
75
  const OUTCOME_ORDER = ['MERGED', 'REWORKED', 'REJECTED', 'UNKNOWN'];
72
76
  /** "2 MERGED · 1 UNKNOWN", fixed order, zeros omitted. */
73
77
  function outcomeSummary(counts) {
@@ -117,7 +121,21 @@ function formatLineageMarkdown(data) {
117
121
  const totalsOutcome = outcomeSummary(t.outcomes);
118
122
  if (totalsOutcome)
119
123
  lines.push(`- Outcomes: ${totalsOutcome}`);
124
+ const f = t.funnel;
125
+ const fSavedSuffix = f.tokensSaved != null ? ` · ~${f.tokensSaved} tokens saved` : '';
126
+ lines.push(`- Disclosure funnel: ${f.impressions} impressions · ` +
127
+ `${f.index} INDEX (${pct(f.index, f.impressions)}%) · ` +
128
+ `${f.pulled} pulled (${pct(f.pulled, f.index)}% of INDEX) · ` +
129
+ `${f.used} used (${pct(f.used, f.impressions)}%)${fSavedSuffix}`);
120
130
  lines.push('');
131
+ if (data.totals.topOperations.length > 0) {
132
+ lines.push('');
133
+ lines.push('Top operations by token cost:');
134
+ for (const op of data.totals.topOperations) {
135
+ lines.push(` ${op.tool} — ${op.total.toLocaleString('en-US')} tokens`);
136
+ }
137
+ lines.push(' (full breakdown: lumo cost --task <id>)');
138
+ }
121
139
  for (const g of data.groups) {
122
140
  lines.push(`## ${(0, sanitize_1.sanitizeField)(g.label)} · ${g.includedAt.slice(0, 10)}`);
123
141
  if (g.cost) {
@@ -130,7 +148,12 @@ function formatLineageMarkdown(data) {
130
148
  lines.push(`**Fragments** (${g.fragments.length}${summary ? `: ${summary}` : ''}):`);
131
149
  lines.push('_✓ used · · abstained · ✗ unused (manual)_');
132
150
  for (const f of g.fragments) {
133
- lines.push(`- ${usageMarker(f.used)} [${f.outcome}] ${f.fragmentType} ${(0, sanitize_1.sanitizeField)(f.sourceLabel)}`);
151
+ const tag = f.disclosure === 'INDEX'
152
+ ? f.pulled
153
+ ? 'INDEX pulled'
154
+ : 'INDEX not-pulled'
155
+ : 'FULL';
156
+ lines.push(`- ${usageMarker(f.used)} [${f.outcome}] ${f.fragmentType} — ${(0, sanitize_1.sanitizeField)(f.sourceLabel)} · ${tag}`);
134
157
  }
135
158
  lines.push('');
136
159
  }
@@ -153,5 +176,12 @@ function formatSignalHealth(h) {
153
176
  else {
154
177
  lines.push('- Used × outcome: insufficient resolved tasks');
155
178
  }
179
+ lines.push(`- Spinoffs during in-flight work: ${h.spinoffsDuringInFlight} (recorded, not yet judged)`);
180
+ const f = h.disclosureFunnel;
181
+ const fSavedSuffix = f.tokensSaved != null ? ` · ~${f.tokensSaved} tokens saved` : '';
182
+ lines.push(`- Disclosure funnel: ${f.impressions} impressions · ` +
183
+ `${f.index} INDEX (${pct(f.index, f.impressions)}%) · ` +
184
+ `${f.pulled} pulled (${pct(f.pulled, f.index)}% of INDEX) · ` +
185
+ `${f.used} used (${pct(f.used, f.impressions)}%)${fSavedSuffix}`);
156
186
  return lines.join('\n');
157
187
  }
@@ -4,6 +4,7 @@ exports.taskSlackShow = taskSlackShow;
4
4
  const config_1 = require("../lib/config");
5
5
  const api_1 = require("../lib/api");
6
6
  const sanitize_1 = require("../lib/sanitize");
7
+ const report_pull_1 = require("../lib/report-pull");
7
8
  /**
8
9
  * `lumo task slack show <LUM-N> <context-id>`
9
10
  *
@@ -50,10 +51,14 @@ async function taskSlackShow(identifier, contextId) {
50
51
  const messages = snapshot?.messages ?? [];
51
52
  if (messages.length === 0) {
52
53
  console.log('(no messages in stored snapshot)');
53
- return;
54
54
  }
55
- for (const m of messages) {
56
- const author = (0, sanitize_1.sanitizeField)(m.userName ?? '@' + m.userId);
57
- console.log(`${author}: ${(0, sanitize_1.sanitizeField)(m.text)}`);
55
+ else {
56
+ for (const m of messages) {
57
+ const author = (0, sanitize_1.sanitizeField)(m.userName ?? '@' + m.userId);
58
+ console.log(`${author}: ${(0, sanitize_1.sanitizeField)(m.text)}`);
59
+ }
58
60
  }
61
+ // LUM-500: stamp the disclosure funnel. The contextId arg == lineage
62
+ // SLACK_CONTEXT fragmentId. Fire-and-forget — never blocks, swallows failures.
63
+ await (0, report_pull_1.reportPull)({ fragmentType: 'SLACK_CONTEXT', fragmentId: contextId });
59
64
  }
@@ -104,6 +104,18 @@ function formatTaskStatus(data, extras = {}) {
104
104
  lines.push(' ⚠ pre-edit version — criterion changed since this check; re-run `lumo verify` to re-confirm');
105
105
  }
106
106
  }
107
+ // LUM-511 Phase 5: send-back lifecycle (was this criterion's send-back
108
+ // resolved, and by which PR).
109
+ const sb = c.sendBackResolution;
110
+ if (sb) {
111
+ if (sb.status === 'resolved') {
112
+ const pr = sb.closingPrNumber ? ` · PR #${sb.closingPrNumber}` : '';
113
+ lines.push(` ↳ send-back (r${sb.failedAtRound}) resolved in r${sb.resolvedAtRound}${pr}`);
114
+ }
115
+ else {
116
+ lines.push(` ↳ send-back (r${sb.failedAtRound}) open`);
117
+ }
118
+ }
107
119
  }
108
120
  if (data.verificationHistory.length > 0) {
109
121
  lines.push('');
@@ -4,6 +4,7 @@ exports.taskWebShow = taskWebShow;
4
4
  const config_1 = require("../lib/config");
5
5
  const api_1 = require("../lib/api");
6
6
  const sanitize_1 = require("../lib/sanitize");
7
+ const report_pull_1 = require("../lib/report-pull");
7
8
  /**
8
9
  * `lumo task web show <LUM-N> <link-id>`
9
10
  *
@@ -58,7 +59,11 @@ async function taskWebShow(identifier, linkId) {
58
59
  const { body } = (await res.json());
59
60
  if (!body || body.trim().length === 0) {
60
61
  console.log('(empty body)');
61
- return;
62
62
  }
63
- console.log((0, sanitize_1.sanitizeField)(body));
63
+ else {
64
+ console.log((0, sanitize_1.sanitizeField)(body));
65
+ }
66
+ // LUM-500: stamp the disclosure funnel. The linkId arg == lineage WEB_LINK
67
+ // fragmentId. Fire-and-forget — never blocks output, swallows failures.
68
+ await (0, report_pull_1.reportPull)({ fragmentType: 'WEB_LINK', fragmentId: linkId });
64
69
  }
@@ -25,10 +25,10 @@ function collectCriterion(value, prev = []) {
25
25
  /**
26
26
  * `lumo verdict [task]` — human + agent acceptance verdicts (LUM-422).
27
27
  *
28
- * Three modes, exactly one required:
29
- * --pass / --pass-with-followup open the browser to the task's verdict bar,
30
- * focused on the passing action (a deep link writes NOTHING; a passing
31
- * data row is only ever produced by a human's own click, red line).
28
+ * Two modes, exactly one required:
29
+ * --pass opens the browser to the task's verdict bar, focused on Pass (a deep
30
+ * link writes NOTHING; a passing data row is only ever produced by a
31
+ * human's own click, red line).
32
32
  * --fail --reason <enum> [--note] [--criterion …] records an AGENT send-back
33
33
  * (verdict hard-coded FAIL server-side) and bounces the task to
34
34
  * IN_PROGRESS. Bearer-authed.
@@ -36,13 +36,9 @@ function collectCriterion(value, prev = []) {
36
36
  * Defaults to the session-bound task; an explicit identifier overrides.
37
37
  */
38
38
  async function verdict(identifier, options = {}) {
39
- const modes = [
40
- options.pass && 'pass',
41
- options.passWithFollowup && 'pass-with-followup',
42
- options.fail && 'fail',
43
- ].filter(Boolean);
39
+ const modes = [options.pass && 'pass', options.fail && 'fail'].filter(Boolean);
44
40
  if (modes.length === 0) {
45
- console.error('Error: choose a verdict mode — --pass, --pass-with-followup, or --fail.');
41
+ console.error('Error: choose a verdict mode — --pass or --fail.');
46
42
  return 1;
47
43
  }
48
44
  if (modes.length > 1) {
@@ -90,14 +86,14 @@ async function verdict(identifier, options = {}) {
90
86
  if (options.fail) {
91
87
  return failVerdict(base, headers, taskId, options, creds.workspaceSlug);
92
88
  }
93
- return passDeepLink(base, headers, taskId, options.passWithFollowup ? 'pass_with_followup' : 'pass', creds.workspaceSlug);
89
+ return passDeepLink(base, headers, taskId, creds.workspaceSlug);
94
90
  }
95
91
  /**
96
- * --pass / --pass-with-followup: open the human's verdict bar pre-focused on the
97
- * passing action. The CLI never writes the verdict — it only carries the human
98
- * to the one click that does (red line: no agent-produced passing row).
92
+ * --pass: open the human's verdict bar pre-focused on Pass. The CLI never writes
93
+ * the verdict — it only carries the human to the one click that does (red line:
94
+ * no agent-produced passing row).
99
95
  */
100
- async function passDeepLink(base, headers, taskId, verdictParam, workspaceSlug) {
96
+ async function passDeepLink(base, headers, taskId, workspaceSlug) {
101
97
  let res;
102
98
  try {
103
99
  res = await fetch(`${base}/api/tasks/by-identifier/${encodeURIComponent(taskId)}`, { headers });
@@ -125,9 +121,8 @@ async function passDeepLink(base, headers, taskId, verdictParam, workspaceSlug)
125
121
  return 1;
126
122
  }
127
123
  const sep = task.url.includes('?') ? '&' : '?';
128
- const deepLink = `${task.url}${sep}verdict=${verdictParam}`;
129
- const label = verdictParam === 'pass_with_followup' ? 'Pass with follow-up' : 'Pass';
130
- process.stdout.write(`Opening ${taskId} for a human "${label}" verdict (nothing is recorded until they click):\n` +
124
+ const deepLink = `${task.url}${sep}verdict=pass`;
125
+ process.stdout.write(`Opening ${taskId} for a human "Pass" verdict (nothing is recorded until they click):\n` +
131
126
  ` ${(0, sanitize_1.sanitizeField)(deepLink)}\n`);
132
127
  (0, browser_1.openBrowser)(deepLink);
133
128
  return;
@@ -47,6 +47,7 @@ const session_attach_1 = require("./commands/session-attach");
47
47
  const session_status_1 = require("./commands/session-status");
48
48
  const session_wrap_1 = require("./commands/session-wrap");
49
49
  const next_1 = require("./commands/next");
50
+ const cost_1 = require("./commands/cost");
50
51
  const verify_1 = require("./commands/verify");
51
52
  const verdict_1 = require("./commands/verdict");
52
53
  const task_context_1 = require("./commands/task-context");
@@ -66,6 +67,7 @@ const memory_project_list_1 = require("./commands/memory-project-list");
66
67
  const memory_project_add_1 = require("./commands/memory-project-add");
67
68
  const memory_promote_1 = require("./commands/memory-promote");
68
69
  const memory_rm_1 = require("./commands/memory-rm");
70
+ const memory_show_1 = require("./commands/memory-show");
69
71
  const task_artifact_add_1 = require("./commands/task-artifact-add");
70
72
  const task_criteria_set_1 = require("./commands/task-criteria-set");
71
73
  const task_criteria_list_1 = require("./commands/task-criteria-list");
@@ -221,9 +223,8 @@ program
221
223
  .action(wrap((task, options) => (0, verify_1.verify)(task, options)));
222
224
  program
223
225
  .command('verdict [task]')
224
- .description('Acceptance verdict (LUM-422). --pass / --pass-with-followup open the browser to the human verdict bar focused on the passing action (a deep link — records nothing; a passing row is only ever a human click). --fail --reason <enum> records an AGENT send-back and bounces the task to IN_PROGRESS. Defaults to the session-bound task.')
226
+ .description('Acceptance verdict (LUM-422). --pass opens the browser to the human verdict bar focused on Pass (a deep link — records nothing; a passing row is only ever a human click). --fail --reason <enum> records an AGENT send-back and bounces the task to IN_PROGRESS. Defaults to the session-bound task.')
225
227
  .option('--pass', 'Open the verdict bar focused on Pass (human one-click; no write)')
226
- .option('--pass-with-followup', 'Open the verdict bar focused on Pass with follow-up (human one-click; no write)')
227
228
  .option('--fail', 'Record an AGENT send-back (verdict FAIL) — requires --reason')
228
229
  .option('--reason <enum>', 'Rejection reason for --fail: CRITERION_UNMET | EVIDENCE_INSUFFICIENT | CHECK_EXECUTION_ERROR | SCOPE_MISMATCH | OTHER (case-insensitive)')
229
230
  .option('--note <text>', 'Optional send-back narrative, posted as a task comment')
@@ -234,6 +235,15 @@ program
234
235
  .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`.')
235
236
  .option('-n, --count <N>', 'Number of tasks to recommend (default 3)')
236
237
  .action(wrap(options => (0, next_1.nextCommand)(options)));
238
+ program
239
+ .command('cost')
240
+ .description('Show per-operation (per-tool) token cost. Defaults to a workspace 30-day window; scope with --task / --session')
241
+ .option('--task <id>', 'Aggregate a single task (e.g. LUM-42)')
242
+ .option('--session <id>', 'Aggregate a single Claude Code session')
243
+ .option('--since <date>', 'Workspace window lower bound (ISO date); default last 30 days')
244
+ .option('--by <dim>', 'Headline grouping: tool | model | member | session (case-insensitive; default tool)')
245
+ .option('--json', 'Emit the versioned payload as JSON')
246
+ .action(wrap(options => (0, cost_1.cost)(options)));
237
247
  const session = program
238
248
  .command('session')
239
249
  .description('Manage per-terminal coding-session context');
@@ -291,6 +301,8 @@ task
291
301
  .option('--tag <name>', 'Attach tag by name (repeatable)', collect, [])
292
302
  .option('--tag-id <cuid>', 'Attach tag by id (repeatable)', collect, [])
293
303
  .option('--sprint <ref>', 'Sprint number or UUID to add the task to after creation')
304
+ .option('--rework-of <id>', 'Declare this is rework of an existing task (redirects you to fix it; creates nothing)')
305
+ .option('--new-scope', 'Declare this is genuinely new work, outside your current task’s scope')
294
306
  .action(wrap((title, options) => (0, task_create_1.taskCreate)(title, options)));
295
307
  const taskFigma = task
296
308
  .command('figma')
@@ -489,6 +501,10 @@ projectMemory
489
501
  const memoryCmd = program
490
502
  .command('memory')
491
503
  .description('Operate on a single memory by id (see `lumo task memory` / `lumo project memory` to list/add)');
504
+ memoryCmd
505
+ .command('show <memoryId>')
506
+ .description("Show one memory's full card by id (category + content). Use to pull the body of a memory you saw as a one-line index entry at session start.")
507
+ .action(wrap((id) => (0, memory_show_1.memoryShow)(id)));
492
508
  memoryCmd
493
509
  .command('promote <memoryId>')
494
510
  .description('Promote a TASK memory to PROJECT scope. Only when the lesson recurs across 2+ tasks.')
@@ -81,6 +81,13 @@ async function resolveDocContent(args) {
81
81
  }
82
82
  if (!args.stdinIsTTY) {
83
83
  const text = await args.readStdin();
84
+ // A non-TTY shell with nothing piped (the common agent/CI case) yields an
85
+ // empty read. Treat empty/whitespace-only stdin as "no content channel"
86
+ // so a title-only `doc update` doesn't ship an empty body and trip the
87
+ // LUM-410 structure guard / blank the document (LUM-505). To clear a body
88
+ // deliberately, pass an explicit `--content ""` (handled above).
89
+ if (text.trim().length === 0)
90
+ return { kind: 'none' };
84
91
  return { kind: 'ok', markdown: text };
85
92
  }
86
93
  return { kind: 'none' };
@@ -12,7 +12,6 @@ const hook_log_1 = require("./hook-log");
12
12
  const sanitize_1 = require("./sanitize");
13
13
  const agent_1 = require("./agent");
14
14
  const git_task_1 = require("./git-task");
15
- const format_1 = require("./format");
16
15
  const transcript_usage_1 = require("./transcript-usage");
17
16
  /**
18
17
  * Hard timeout for the hook POST. On timeout the request is aborted,
@@ -72,7 +71,11 @@ function readStdin() {
72
71
  * The JSON lines conform to Claude Code's hookSpecificOutput envelope so the
73
72
  * runtime injects additionalContext into the conversation automatically.
74
73
  */
75
- function formatHookStdoutLines(path, responseBody, now = new Date()) {
74
+ function formatHookStdoutLines(path, responseBody,
75
+ // Retained for signature stability (callers/tests still pass it). LUM-500
76
+ // removed the only time-dependent rendering (the recovery card), so it is no
77
+ // longer read.
78
+ _now = new Date()) {
76
79
  if (path === 'pre-tool-use') {
77
80
  if (responseBody == null || typeof responseBody !== 'object')
78
81
  return [];
@@ -107,20 +110,27 @@ function formatHookStdoutLines(path, responseBody, now = new Date()) {
107
110
  else if (tb && tb.bound === false) {
108
111
  lines.push(unboundPromptLine(sessionId));
109
112
  }
110
- // Recovery card + blocker warning + memory + linked resources + PR-review
111
- // todos share one additionalContext block so Claude Code injects a single
112
- // coherent context payload at session start. The card slots in first so it's
113
- // the first thing the model reads; the dependency blocker warning (LUM-172)
114
- // comes right after so it stays prominent, ahead of the memory section.
115
- const card = renderRecoveryCard(body.previousSession, tb?.taskIdentifier ?? '', now);
113
+ // Blocker warning + criteria + progress + memory + linked resources +
114
+ // PR-review todos share one additionalContext block so Claude Code injects a
115
+ // single coherent context payload at session start. The dependency blocker
116
+ // warning (LUM-172) slots in first so it stays prominent it can preempt the
117
+ // session's work entirely. Then the progressive-disclosure tiers (LUM-500):
118
+ // Tier-0 acceptance contract Tier-1 prior-session progress → Tier-2 memory
119
+ // index → linked resources.
120
+ //
121
+ // LUM-500: the prior-session recovery is now the server-rendered Tier-1
122
+ // `progressSection`, which REPLACES the CLI-rendered recovery card so progress
123
+ // isn't injected twice. `body.previousSession` is still sent (it drives the
124
+ // server's lineage/metrics) but is no longer rendered here.
116
125
  const envelope = sessionContextEnvelope([
117
- card,
118
- // Blocker warning right after the card: it can preempt the session's
119
- // work entirely (wait for the blocker instead of starting) — LUM-172.
126
+ // Blocker warning first: it can preempt the session's work entirely (wait
127
+ // for the blocker instead of starting) LUM-172.
120
128
  body.blockerWarningSection,
121
- // Acceptance contract next: it's what the session's work is judged
122
- // against (LUM-342).
129
+ // Tier-0 acceptance contract: what the session's work is judged against
130
+ // (LUM-342).
123
131
  body.criteriaSection,
132
+ // Tier-1 prior-session progress (LUM-500), server-rendered.
133
+ body.progressSection,
124
134
  body.memorySection,
125
135
  body.linkedResourcesSection,
126
136
  body.reviewTodosSection,
@@ -137,37 +147,6 @@ function formatHookStdoutLines(path, responseBody, now = new Date()) {
137
147
  function unboundPromptLine(sessionId) {
138
148
  return `[Lumo] session_id=${sessionId} | No task bound. Tell me the task you want to work on (e.g. LUM-42), or say "skip".`;
139
149
  }
140
- const MAX_UNRESOLVED = 5;
141
- /**
142
- * Render the "resuming previous session" recovery card from the structured previousSession
143
- * payload. Returns undefined when there's nothing to show (null payload or an
144
- * empty headline). Free text (headline / unresolved) is sanitized here — it's
145
- * LLM-generated and routed to Claude Code stdout. unresolved is capped at
146
- * MAX_UNRESOLVED with a "+M more" pointer to `lumo task context`.
147
- */
148
- function renderRecoveryCard(prev, taskIdentifier, now) {
149
- if (!prev || typeof prev.headline !== 'string' || prev.headline === '') {
150
- return undefined;
151
- }
152
- const ago = (0, format_1.relativeTime)(new Date(prev.lastActivityAt), now);
153
- const dur = (0, format_1.formatDuration)(prev.durationMs);
154
- const lines = [
155
- `## Resuming previous session (${ago} · ${dur})`,
156
- `Last stopped at: ${(0, sanitize_1.sanitizeField)(prev.headline)}`,
157
- ];
158
- const unresolved = Array.isArray(prev.unresolved) ? prev.unresolved : [];
159
- if (unresolved.length > 0) {
160
- lines.push('Unfinished:');
161
- const shown = unresolved.slice(0, MAX_UNRESOLVED);
162
- for (const u of shown)
163
- lines.push(`- ${(0, sanitize_1.sanitizeField)(u)}`);
164
- const extra = unresolved.length - shown.length;
165
- if (extra > 0) {
166
- lines.push(`- … (+${extra} more — run \`lumo task context ${taskIdentifier}\` for the full list)`);
167
- }
168
- }
169
- return lines.join('\n');
170
- }
171
150
  /**
172
151
  * Wrap any non-empty context parts into a single SessionStart
173
152
  * hookSpecificOutput envelope so Claude Code injects one coherent
@@ -204,7 +183,9 @@ function formatSuggestLine(sessionId, match) {
204
183
  * detect-and-suggest, never auto-bind). No match falls back to the generic
205
184
  * unbound prompt.
206
185
  */
207
- function resolveSessionStartStdout(responseBody, deps, now = new Date()) {
186
+ function resolveSessionStartStdout(responseBody, deps,
187
+ // Retained for signature stability; no longer read (see formatHookStdoutLines).
188
+ _now = new Date()) {
208
189
  if (responseBody == null || typeof responseBody !== 'object')
209
190
  return [];
210
191
  const body = responseBody;
@@ -213,7 +194,7 @@ function resolveSessionStartStdout(responseBody, deps, now = new Date()) {
213
194
  const sessionId = body.sessionId;
214
195
  const tb = body.taskBinding;
215
196
  if (tb && tb.bound === true) {
216
- return formatHookStdoutLines('session-start', responseBody, now);
197
+ return formatHookStdoutLines('session-start', responseBody);
217
198
  }
218
199
  if (!tb || tb.bound !== false)
219
200
  return [];
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reportPull = reportPull;
4
+ const config_1 = require("./config");
5
+ const api_1 = require("./api");
6
+ /**
7
+ * Fire-and-forget telemetry for progressive disclosure (LUM-500).
8
+ *
9
+ * When an agent pulls a fragment's body via a `lumo … show <id>` command, this
10
+ * reports the pull to the server, which stamps `pulledAt` on the session-start
11
+ * lineage edge — the middle of the disclosure funnel (saw → pulled → used).
12
+ *
13
+ * Contract:
14
+ * - NO-OP silently when there is no bound session (nothing is sent). The bound
15
+ * session id is `CLAUDE_CODE_SESSION_ID` — the same source other commands use
16
+ * for `X-Lumo-Session-Id` provenance.
17
+ * - Fire-and-forget: never blocks the command's main output and never surfaces
18
+ * an error. All failures (no creds, network, non-ok) are swallowed. Returns
19
+ * void so callers can `await` it without affecting their exit code.
20
+ *
21
+ * `fragmentId` must be the DB row id the lineage edge stored — callers are
22
+ * responsible for passing the id that matches (see each command's wiring).
23
+ */
24
+ async function reportPull(ref) {
25
+ try {
26
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
27
+ if (!sessionId)
28
+ return;
29
+ const creds = (0, config_1.readCredentials)();
30
+ if (!creds)
31
+ return;
32
+ const base = (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
33
+ await fetch(`${base}/api/lineage/pulls`, {
34
+ method: 'POST',
35
+ headers: {
36
+ Authorization: `Bearer ${creds.token}`,
37
+ 'Content-Type': 'application/json',
38
+ },
39
+ body: JSON.stringify({
40
+ sessionId,
41
+ fragmentType: ref.fragmentType,
42
+ fragmentId: ref.fragmentId,
43
+ }),
44
+ });
45
+ }
46
+ catch {
47
+ // fire-and-forget: never surface telemetry failures
48
+ }
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",