@lumoai/cli 1.37.0 → 1.39.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.
@@ -82,10 +82,11 @@ The command catalog below is a **map**: it lists every command grouped by domain
82
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.**
83
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
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
+ - `lumo crossing explain <id> --note "<text>"` — append an agent self-explanation ("申辩") to a boundary crossing (LUM-542). The **inverse** of dispositioning: this is the agent/CLI path (bearer-only; a clerk/human caller is refused), but it can **only append** an append-only note — it **never clears the crossing or unblocks Done** (disposition stays web + human-only, LUM-448). The note is shown to the human reviewer at disposition time and kept for later review, explicitly labeled _agent self-report · unverified_. Scope: `<id>` must be a crossing on the **session-bound task** (resolved from `$CLAUDE_CODE_SESSION_ID`; cross-task targets and unbound/mismatched sessions are rejected). Earlier explanations are immutable — a correction is a new note. **When to suggest:** after `lumo task status` (or the next pre-tool-use hook's one-time reminder) surfaces an OPEN crossing you believe is a false positive or want to leave a rationale for — record it here; it's a review aid, not a way to self-clear the gate.
85
86
 
86
87
  **Cost (per-operation token read-out)** — see [task-context.md](references/task-context.md)
87
88
 
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`.
89
+ - `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); each ranking row breaks the cost into output / input / cache_create / cache_read columns plus total, with a per-step coverage line and a "heuristic" note (parallel tools split a step's tokens evenly; output and cache_read attribute cleanly per step while input and cache_create are bursty, so per-tool figures for those two are coarse). 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`.
89
90
 
90
91
  **Artifacts & Figma** — see [artifacts-figma.md](references/artifacts-figma.md)
91
92
 
@@ -187,8 +187,11 @@ Per-operation (per-tool) token cost read-out. Where `task lineage` answers
187
187
  token delta to the tool(s) that ran in it — **per-step** where the
188
188
  `POST_TOOL_BATCH` hook captured the tool list, **per-turn fallback** otherwise
189
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.
190
+ "heuristic" note). Each ranking row breaks the cost into four columns
191
+ `output` (generation), `input`, `cache_create`, `cache_read` (~95%, structural —
192
+ turns × context) — plus `total`. `output` and `cache_read` attribute cleanly
193
+ per step; `input` and `cache_create` are bursty (prompt + cache checkpoints), so
194
+ their per-tool figures are coarser.
192
195
 
193
196
  ```bash
194
197
  lumo cost --task LUM-42
@@ -10,15 +10,19 @@ function groupThousands(value) {
10
10
  .toString()
11
11
  .replace(/\B(?=(\d{3})+(?!\d))/g, ',');
12
12
  }
13
+ /** Render one ranking row: label + the four token categories + total, aligned. */
14
+ function rankRow(label, output, input, cacheCreate, cacheRead, total) {
15
+ return ` ${label.slice(0, 20).padEnd(20)} ${output.padStart(10)} ${input.padStart(10)} ${cacheCreate.padStart(12)} ${cacheRead.padStart(12)} ${total.padStart(12)}`;
16
+ }
13
17
  function rankTable(title, rows) {
14
18
  if (rows.length === 0)
15
19
  return '';
16
20
  const lines = [
17
21
  title,
18
- ' operation output cache_read total',
22
+ rankRow('operation', 'output', 'input', 'cache_create', 'cache_read', 'total'),
19
23
  ];
20
24
  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)}`);
25
+ lines.push(rankRow(r.label, groupThousands(r.output), groupThousands(r.input), groupThousands(r.cacheCreation), groupThousands(r.cacheRead), groupThousands(r.total)));
22
26
  }
23
27
  return lines.join('\n');
24
28
  }
@@ -39,7 +43,7 @@ function formatOperationCost(data, by) {
39
43
  : `${Math.round(data.coverage.perStepPct * 100)}%`;
40
44
  const out = [];
41
45
  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`);
46
+ out.push(`Total: output ${groupThousands(data.grandTotal.output)} · input ${groupThousands(data.grandTotal.input)} · cache_create ${groupThousands(data.grandTotal.cacheCreation)} · cache_read ${groupThousands(data.grandTotal.cacheRead)} · all ${groupThousands(data.grandTotal.total)} tokens`);
43
47
  out.push(`Per-step attribution: ${pct} of ${data.coverage.toolTurns} tool-using turns (rest fall back to per-turn split)`);
44
48
  out.push('');
45
49
  out.push(rankTable(`By ${by}:`, primary));
@@ -52,7 +56,7 @@ function formatOperationCost(data, by) {
52
56
  if (by !== 'session' && data.bySession.length > 1)
53
57
  out.push('\n' + rankTable('By session:', data.bySession));
54
58
  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).');
59
+ out.push('Note: token→tool attribution is heuristic — a model step that fires several tools in parallel splits its tokens evenly across them. output (generation) and cache_read (~95%, structural turns × context) attribute cleanly per step; input and cache_create are bursty (prompt + cache checkpoints) and per-tool figures for those two are coarse.');
56
60
  return out.join('\n');
57
61
  }
58
62
  async function cost(opts) {
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.crossingExplain = crossingExplain;
4
+ const config_1 = require("../lib/config");
5
+ const api_1 = require("../lib/api");
6
+ const sanitize_1 = require("../lib/sanitize");
7
+ /**
8
+ * `lumo crossing explain <id> --note "…"` — append an agent self-explanation
9
+ * ("申辩") to a boundary crossing (LUM-542).
10
+ *
11
+ * This is the AGENT side of the boundary-crossing review loop and the deliberate
12
+ * inverse of dispositioning: it can only ADD an append-only note for the human
13
+ * reviewer to weigh — it never clears the crossing or unblocks Done (a human
14
+ * dispositions that, in the web acceptance panel). The crossing must belong to
15
+ * the task this session is bound to; the binding is how the target task is
16
+ * resolved, so run it inside a session attached via `lumo session attach`.
17
+ */
18
+ async function crossingExplain(crossingId, options = {}) {
19
+ if (!crossingId || crossingId.trim() === '') {
20
+ console.error('Error: a crossing id is required: lumo crossing explain <id> --note "…"');
21
+ return 1;
22
+ }
23
+ const note = options.note?.trim();
24
+ if (!note) {
25
+ console.error('Error: --note "<text>" is required (the explanation to record).');
26
+ return 1;
27
+ }
28
+ const creds = (0, config_1.readCredentials)();
29
+ if (!creds) {
30
+ console.error('Error: not logged in. Run `lumo auth login` first.');
31
+ return 1;
32
+ }
33
+ const base = (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
34
+ const headers = {
35
+ Authorization: `Bearer ${creds.token}`,
36
+ };
37
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
38
+ if (sessionId)
39
+ headers['X-Lumo-Session-Id'] = sessionId;
40
+ // The crossing is addressed by id, but the route is task-scoped — resolve the
41
+ // bound task from the session so the server can verify the crossing is on it.
42
+ if (!sessionId) {
43
+ console.error('Error: $CLAUDE_CODE_SESSION_ID is not set — run inside a session bound via `lumo session attach <LUM-N>`.');
44
+ return 1;
45
+ }
46
+ let bound;
47
+ try {
48
+ const res = await fetch(`${base}/api/sessions/${encodeURIComponent(sessionId)}`, { headers });
49
+ bound = res.ok
50
+ ? (await res.json())
51
+ : null;
52
+ }
53
+ catch (err) {
54
+ const msg = err instanceof Error ? err.message : String(err);
55
+ console.error(`Error: could not reach Lumo API (${msg})`);
56
+ return 1;
57
+ }
58
+ if (!bound?.taskIdentifier) {
59
+ console.error('Error: this session is not bound to a task. Run `lumo session attach <LUM-N>` first.');
60
+ return 1;
61
+ }
62
+ const taskId = bound.taskIdentifier;
63
+ let res;
64
+ try {
65
+ res = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}/boundary-crossings/${encodeURIComponent(crossingId)}/explain`, {
66
+ method: 'POST',
67
+ headers: { ...headers, 'Content-Type': 'application/json' },
68
+ body: JSON.stringify({ note }),
69
+ });
70
+ }
71
+ catch (err) {
72
+ const msg = err instanceof Error ? err.message : String(err);
73
+ console.error(`Error: could not reach Lumo API (${msg})`);
74
+ return 1;
75
+ }
76
+ if (res.status === 401) {
77
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
78
+ return 1;
79
+ }
80
+ if (!res.ok) {
81
+ const errBody = (await res.json().catch(() => null));
82
+ const detail = errBody && typeof errBody.error === 'string'
83
+ ? (0, sanitize_1.sanitizeField)(errBody.error)
84
+ : '';
85
+ console.error(`Error: explanation rejected (HTTP ${res.status})${detail ? ` — ${detail}` : ''}`);
86
+ return 1;
87
+ }
88
+ const outcome = (await res.json());
89
+ process.stdout.write(`✓ Recorded an explanation on crossing ${(0, sanitize_1.sanitizeField)(outcome.crossingId)}.\n` +
90
+ ' This is an append-only note for the human reviewer — it does not clear ' +
91
+ 'the crossing or unblock Done.\n');
92
+ return;
93
+ }
@@ -50,6 +50,7 @@ const next_1 = require("./commands/next");
50
50
  const cost_1 = require("./commands/cost");
51
51
  const verify_1 = require("./commands/verify");
52
52
  const verdict_1 = require("./commands/verdict");
53
+ const crossing_explain_1 = require("./commands/crossing-explain");
53
54
  const task_context_1 = require("./commands/task-context");
54
55
  const task_create_1 = require("./commands/task-create");
55
56
  const task_update_1 = require("./commands/task-update");
@@ -230,6 +231,14 @@ program
230
231
  .option('--note <text>', 'Optional send-back narrative, posted as a task comment')
231
232
  .option('--criterion <id>', 'Narrow a --fail to specific criteria (repeatable); omitted = the whole contract', verdict_1.collectCriterion)
232
233
  .action(wrap((task, options) => (0, verdict_1.verdict)(task, options)));
234
+ const crossing = program
235
+ .command('crossing')
236
+ .description('Inspect and annotate boundary crossings');
237
+ crossing
238
+ .command('explain <id>')
239
+ .description('Append an agent self-explanation ("申辩") to a boundary crossing (LUM-542). Append-only and for the human reviewer — it never clears the crossing or unblocks Done (a human dispositions that). Targets a crossing on the session-bound task.')
240
+ .requiredOption('--note <text>', 'The explanation to record (the rationale for the action / why it may be a false positive)')
241
+ .action(wrap((id, options) => (0, crossing_explain_1.crossingExplain)(id, options)));
233
242
  program
234
243
  .command('next')
235
244
  .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`.')
@@ -79,9 +79,17 @@ _now = new Date()) {
79
79
  if (path === 'pre-tool-use') {
80
80
  if (responseBody == null || typeof responseBody !== 'object')
81
81
  return [];
82
- const warning = responseBody
83
- .collisionWarning;
84
- if (typeof warning !== 'string' || warning === '')
82
+ const body = responseBody;
83
+ // Two independent PreToolUse signals (LUM-150 collision, LUM-542 crossing
84
+ // reminder) share one additionalContext block when both are present.
85
+ const parts = [];
86
+ if (typeof body.collisionWarning === 'string' &&
87
+ body.collisionWarning !== '')
88
+ parts.push(body.collisionWarning);
89
+ if (typeof body.crossingReminder === 'string' &&
90
+ body.crossingReminder !== '')
91
+ parts.push(body.crossingReminder);
92
+ if (parts.length === 0)
85
93
  return [];
86
94
  return [
87
95
  JSON.stringify({
@@ -89,7 +97,7 @@ _now = new Date()) {
89
97
  hookEventName: 'PreToolUse',
90
98
  // Server-built text routed back to stdout — sanitize untrusted free
91
99
  // text before Claude Code consumes it (ANSI/control-char injection).
92
- additionalContext: (0, sanitize_1.sanitizeField)(warning),
100
+ additionalContext: (0, sanitize_1.sanitizeField)(parts.join('\n\n')),
93
101
  },
94
102
  }),
95
103
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.37.0",
3
+ "version": "1.39.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",