@lumoai/cli 1.35.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.
@@ -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
  }
@@ -141,10 +164,24 @@ function formatSignalHealth(h) {
141
164
  lines.push(`- Distribution: used ${h.distribution.used} · null ${h.distribution.abstained} · false ${h.distribution.unused}`);
142
165
  lines.push(`- Per-session variance: ${h.perSessionVariance.toFixed(2)} (${h.votedSessions} voted sessions)`);
143
166
  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)}%`);
167
+ if (h.baseFailedTasks === 0) {
168
+ // No failure outcomes exist yet, so any rate is non-discriminating by
169
+ // construction — say so honestly instead of printing a misleading 100%.
170
+ lines.push('- Used × outcome: no failure outcomes yet — metric cannot discriminate');
171
+ }
172
+ else {
173
+ lines.push(`- Used × outcome: merge-rate(used) ${Math.round(h.usedMergeRate * 100)}% (${h.usedResolvedTasks} resolved, ${h.usedFailedTasks} failed) vs base ${Math.round(h.baseMergeRate * 100)}% (${h.baseResolvedTasks} resolved, ${h.baseFailedTasks} failed)`);
174
+ }
145
175
  }
146
176
  else {
147
177
  lines.push('- Used × outcome: insufficient resolved tasks');
148
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}`);
149
186
  return lines.join('\n');
150
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;
@@ -181,6 +181,14 @@ async function verify(identifier, options = {}) {
181
181
  }
182
182
  const outcome = (await res.json());
183
183
  process.stdout.write(`\nRound ${outcome.round}/${outcome.maxRounds} recorded.\n`);
184
+ if (outcome.bindingAdvisory === 'unbound') {
185
+ process.stdout.write('⚠ Working unbound — this verify ran from a Claude Code session not attached to the task. ' +
186
+ 'Run `lumo session attach <LUM-N>` to bind (recorded as a boundary crossing).\n');
187
+ }
188
+ else if (outcome.bindingAdvisory === 'unconfirmed') {
189
+ process.stdout.write('⚠ Could not confirm this session is attached to the task. ' +
190
+ 'If you are working with Claude Code, run `lumo session attach <LUM-N>`.\n');
191
+ }
184
192
  if (outcome.allPassed) {
185
193
  process.stdout.write(`✓ All MACHINE criteria passed — task is now ${outcome.taskStatus}.\n` +
186
194
  `Stop here: human adjudication (and any HUMAN criteria) take over from this point.\n`);
@@ -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');
@@ -247,9 +257,9 @@ session
247
257
  .action(wrap(() => (0, session_status_1.sessionStatus)()));
248
258
  session
249
259
  .command('wrap')
250
- .description("Session-end wrap-up: draft a progress comment from this session's turn summaries and post it to the bound task after confirmation.")
251
- .option('-y, --yes', 'Post the drafted comment without prompting (agent-friendly)')
252
- .option('--dry-run', 'Print the draft but do not post or advance the watermark')
260
+ .description('Session-end wrap-up: review the memories sedimented this session, vote which injected context fragments were actually used, and optionally flag the bound task blocked.')
261
+ .option('-y, --yes', 'Keep all memories without prompting (agent-friendly); does not auto-apply the blocked tag')
262
+ .option('--dry-run', 'Print the section drafts but do not mutate memories/tags or advance watermarks')
253
263
  .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.')
254
264
  .action(wrap(options => (0, session_wrap_1.sessionWrap)(options)));
255
265
  const task = program
@@ -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.35.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",
@@ -1,81 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ProgressCommentSection = void 0;
4
- exports.formatProgressBody = formatProgressBody;
5
- const sanitize_1 = require("../../lib/sanitize");
6
- const line_prompt_1 = require("../../lib/line-prompt");
7
- const editor_1 = require("../../lib/editor");
8
- const progress_comment_api_1 = require("../../lib/progress-comment-api");
9
- const HEADER = 'Session progress';
10
- /** Join turn summaries into a bulleted progress comment body under a header. */
11
- function formatProgressBody(summaries) {
12
- return [HEADER, ...summaries.map(s => `- ${s}`)].join('\n');
13
- }
14
- /**
15
- * Wrap-panel section that drafts a progress comment from the session's
16
- * unposted turnSummaries and posts it after y/e/s confirmation. Holds its own
17
- * draft + body state between prepare() and run().
18
- */
19
- class ProgressCommentSection {
20
- deps;
21
- title = 'Progress comment';
22
- draft = null;
23
- body = '';
24
- constructor(deps) {
25
- this.deps = deps;
26
- }
27
- async prepare() {
28
- this.draft = await (0, progress_comment_api_1.fetchProgressDraft)(this.deps.creds, this.deps.sessionId);
29
- if (!this.draft.taskIdentifier || this.draft.summaries.length === 0) {
30
- return false;
31
- }
32
- this.body = formatProgressBody(this.draft.summaries.map(s => s.turnSummary));
33
- return true;
34
- }
35
- async run(opts) {
36
- const draft = this.draft;
37
- if (!draft || !draft.watermark)
38
- return;
39
- // Preview: sanitize the server free-text before it hits the terminal.
40
- process.stdout.write(`Will post to ${draft.taskIdentifier} "${(0, sanitize_1.sanitizeField)(draft.taskTitle ?? '')}":\n`);
41
- process.stdout.write(`${(0, sanitize_1.sanitizeField)(this.body)}\n`);
42
- if (opts.dryRun) {
43
- process.stdout.write('(dry-run, not posted)\n');
44
- return;
45
- }
46
- if (opts.yes) {
47
- await this.post(draft.watermark, this.body);
48
- return;
49
- }
50
- const choice = (await (0, line_prompt_1.promptLine)('[y] post [e] edit [s] skip > ')).toLowerCase();
51
- if (choice === 's' || choice === '') {
52
- process.stdout.write('Skipped.\n');
53
- return;
54
- }
55
- if (choice === 'e') {
56
- const edited = (await (0, editor_1.editInEditor)(this.body)).trim();
57
- if (edited.length === 0) {
58
- process.stdout.write('Empty body — skipped.\n');
59
- return;
60
- }
61
- process.stdout.write(`${(0, sanitize_1.sanitizeField)(edited)}\n`);
62
- const confirm = (await (0, line_prompt_1.promptLine)('[y] post [s] skip > ')).toLowerCase();
63
- if (confirm !== 'y') {
64
- process.stdout.write('Skipped.\n');
65
- return;
66
- }
67
- await this.post(draft.watermark, edited);
68
- return;
69
- }
70
- if (choice === 'y') {
71
- await this.post(draft.watermark, this.body);
72
- return;
73
- }
74
- process.stdout.write('Unrecognized choice — skipped.\n');
75
- }
76
- async post(watermark, body) {
77
- const { commentId } = await (0, progress_comment_api_1.postProgressComment)(this.deps.creds, this.deps.sessionId, { body, watermark });
78
- process.stdout.write(`Posted progress comment (comment ${commentId})\n`);
79
- }
80
- }
81
- exports.ProgressCommentSection = ProgressCommentSection;