@lumoai/cli 1.7.0 → 1.8.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.
@@ -5,6 +5,7 @@ exports.formatTaskContextMarkdown = formatTaskContextMarkdown;
5
5
  const config_1 = require("../lib/config");
6
6
  const api_1 = require("../lib/api");
7
7
  const sanitize_1 = require("../lib/sanitize");
8
+ const format_1 = require("../lib/format");
8
9
  async function taskContext(identifier) {
9
10
  if (!identifier) {
10
11
  console.error('Error: missing <identifier>. Usage: lumo task context <LUM-42>');
@@ -115,8 +116,8 @@ function formatTaskContextMarkdown(data, now) {
115
116
  lines.push('');
116
117
  for (const s of data.sessions) {
117
118
  const shortId = s.id.slice(0, 8);
118
- const ago = relativeTime(new Date(s.lastActivityAt), now);
119
- const dur = formatDuration(s.durationMs);
119
+ const ago = (0, format_1.relativeTime)(new Date(s.lastActivityAt), now);
120
+ const dur = (0, format_1.formatDuration)(s.durationMs);
120
121
  lines.push(`### Session ${shortId} · ${ago} · ${dur}`);
121
122
  lines.push(`**Summary**: ${(0, sanitize_1.sanitizeField)(s.headline)}`);
122
123
  if (s.unresolved.length > 0) {
@@ -128,36 +129,3 @@ function formatTaskContextMarkdown(data, now) {
128
129
  }
129
130
  return lines.join('\n');
130
131
  }
131
- function formatDuration(ms) {
132
- if (ms < 60_000) {
133
- return `${Math.max(1, Math.round(ms / 1000))}s`;
134
- }
135
- const totalMinutes = Math.round(ms / 60_000);
136
- if (totalMinutes < 60)
137
- return `${totalMinutes}min`;
138
- const hours = Math.floor(totalMinutes / 60);
139
- const minutes = totalMinutes % 60;
140
- return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}min`;
141
- }
142
- function relativeTime(then, now) {
143
- const diffMs = now.getTime() - then.getTime();
144
- const seconds = Math.floor(diffMs / 1000);
145
- if (seconds < 60)
146
- return 'just now';
147
- const minutes = Math.floor(seconds / 60);
148
- if (minutes < 60)
149
- return `${minutes}min ago`;
150
- const hours = Math.floor(minutes / 60);
151
- if (hours < 24)
152
- return `${hours}h ago`;
153
- const days = Math.floor(hours / 24);
154
- if (days === 1)
155
- return 'yesterday';
156
- if (days < 7)
157
- return `${days} days ago`;
158
- const weeks = Math.floor(days / 7);
159
- if (weeks < 5)
160
- return `${weeks}w ago`;
161
- const months = Math.floor(days / 30);
162
- return `${months}mo ago`;
163
- }
@@ -1,7 +1,46 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatDuration = formatDuration;
4
+ exports.relativeTime = relativeTime;
3
5
  exports.formatTaskListTable = formatTaskListTable;
4
6
  const sanitize_1 = require("./sanitize");
7
+ /**
8
+ * Shared CLI time/duration formatters. Extracted from task-context.ts so the
9
+ * session-start recovery card (hook-runner.ts) can reuse the exact same shape.
10
+ */
11
+ function formatDuration(ms) {
12
+ if (ms < 60_000) {
13
+ return `${Math.max(1, Math.round(ms / 1000))}s`;
14
+ }
15
+ const totalMinutes = Math.round(ms / 60_000);
16
+ if (totalMinutes < 60)
17
+ return `${totalMinutes}min`;
18
+ const hours = Math.floor(totalMinutes / 60);
19
+ const minutes = totalMinutes % 60;
20
+ return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}min`;
21
+ }
22
+ function relativeTime(then, now) {
23
+ const diffMs = now.getTime() - then.getTime();
24
+ const seconds = Math.floor(diffMs / 1000);
25
+ if (seconds < 60)
26
+ return 'just now';
27
+ const minutes = Math.floor(seconds / 60);
28
+ if (minutes < 60)
29
+ return `${minutes}min ago`;
30
+ const hours = Math.floor(minutes / 60);
31
+ if (hours < 24)
32
+ return `${hours}h ago`;
33
+ const days = Math.floor(hours / 24);
34
+ if (days === 1)
35
+ return 'yesterday';
36
+ if (days < 7)
37
+ return `${days} days ago`;
38
+ const weeks = Math.floor(days / 7);
39
+ if (weeks < 5)
40
+ return `${weeks}w ago`;
41
+ const months = Math.floor(days / 30);
42
+ return `${months}mo ago`;
43
+ }
5
44
  /**
6
45
  * Render a task list as a fixed-width table. Each row:
7
46
  * LUM-42 IN_PROGRESS HIGH project-name Title here
@@ -11,6 +11,7 @@ const hook_log_1 = require("./hook-log");
11
11
  const sanitize_1 = require("./sanitize");
12
12
  const agent_1 = require("./agent");
13
13
  const git_task_1 = require("./git-task");
14
+ const format_1 = require("./format");
14
15
  /**
15
16
  * Hard timeout for the hook POST. On timeout the request is aborted,
16
17
  * logged, and `runHook` exits 0 — Claude Code is never blocked beyond
@@ -69,7 +70,7 @@ function readStdin() {
69
70
  * The JSON lines conform to Claude Code's hookSpecificOutput envelope so the
70
71
  * runtime injects additionalContext into the conversation automatically.
71
72
  */
72
- function formatHookStdoutLines(path, responseBody) {
73
+ function formatHookStdoutLines(path, responseBody, now = new Date()) {
73
74
  if (path === 'pre-tool-use') {
74
75
  if (responseBody == null || typeof responseBody !== 'object')
75
76
  return [];
@@ -104,9 +105,12 @@ function formatHookStdoutLines(path, responseBody) {
104
105
  else if (tb && tb.bound === false) {
105
106
  lines.push(unboundPromptLine(sessionId));
106
107
  }
107
- // Memory + PR-review todos share one additionalContext block so Claude Code
108
- // injects a single coherent context payload at session start.
108
+ // Recovery card + memory + PR-review todos share one additionalContext block
109
+ // so Claude Code injects a single coherent context payload at session start.
110
+ // The card slots in first so it's the first thing the model reads.
111
+ const card = renderRecoveryCard(body.previousSession, tb?.taskIdentifier ?? '', now);
109
112
  const envelope = sessionContextEnvelope([
113
+ card,
110
114
  body.memorySection,
111
115
  body.reviewTodosSection,
112
116
  ]);
@@ -121,6 +125,37 @@ function formatHookStdoutLines(path, responseBody) {
121
125
  function unboundPromptLine(sessionId) {
122
126
  return `[Lumo] session_id=${sessionId} | 当前未绑定任务。请告诉我你要处理的任务编号(如 LUM-42),或说"跳过"。`;
123
127
  }
128
+ const MAX_UNRESOLVED = 5;
129
+ /**
130
+ * Render the "续上次进度" recovery card from the structured previousSession
131
+ * payload. Returns undefined when there's nothing to show (null payload or an
132
+ * empty headline). Free text (headline / unresolved) is sanitized here — it's
133
+ * LLM-generated and routed to Claude Code stdout. unresolved is capped at
134
+ * MAX_UNRESOLVED with a "+M more" pointer to `lumo task context`.
135
+ */
136
+ function renderRecoveryCard(prev, taskIdentifier, now) {
137
+ if (!prev || typeof prev.headline !== 'string' || prev.headline === '') {
138
+ return undefined;
139
+ }
140
+ const ago = (0, format_1.relativeTime)(new Date(prev.lastActivityAt), now);
141
+ const dur = (0, format_1.formatDuration)(prev.durationMs);
142
+ const lines = [
143
+ `## 续上次进度 (上一段 session · ${ago} · ${dur})`,
144
+ `上次干到:${(0, sanitize_1.sanitizeField)(prev.headline)}`,
145
+ ];
146
+ const unresolved = Array.isArray(prev.unresolved) ? prev.unresolved : [];
147
+ if (unresolved.length > 0) {
148
+ lines.push('未完成:');
149
+ const shown = unresolved.slice(0, MAX_UNRESOLVED);
150
+ for (const u of shown)
151
+ lines.push(`- ${(0, sanitize_1.sanitizeField)(u)}`);
152
+ const extra = unresolved.length - shown.length;
153
+ if (extra > 0) {
154
+ lines.push(`- …(+${extra} more, 跑 lumo task context ${taskIdentifier} 查看全部)`);
155
+ }
156
+ }
157
+ return lines.join('\n');
158
+ }
124
159
  /**
125
160
  * Wrap any non-empty context parts into a single SessionStart
126
161
  * hookSpecificOutput envelope so Claude Code injects one coherent
@@ -153,7 +188,7 @@ function formatAutoBindLine(sessionId, match, result) {
153
188
  * bound task's memory). Falls back to the unbound prompt when nothing matches
154
189
  * or the bind fails. Bound sessions reuse the existing formatting.
155
190
  */
156
- async function resolveSessionStartStdout(responseBody, deps) {
191
+ async function resolveSessionStartStdout(responseBody, deps, now = new Date()) {
157
192
  if (responseBody == null || typeof responseBody !== 'object')
158
193
  return [];
159
194
  const body = responseBody;
@@ -162,7 +197,7 @@ async function resolveSessionStartStdout(responseBody, deps) {
162
197
  const sessionId = body.sessionId;
163
198
  const tb = body.taskBinding;
164
199
  if (tb && tb.bound === true) {
165
- return formatHookStdoutLines('session-start', responseBody);
200
+ return formatHookStdoutLines('session-start', responseBody, now);
166
201
  }
167
202
  if (!tb || tb.bound !== false)
168
203
  return [];
@@ -173,7 +208,11 @@ async function resolveSessionStartStdout(responseBody, deps) {
173
208
  if (!result.ok)
174
209
  return [unboundPromptLine(sessionId)];
175
210
  const lines = [formatAutoBindLine(sessionId, match, result)];
176
- const envelope = sessionContextEnvelope([result.memorySection]);
211
+ const card = renderRecoveryCard(result.previousSession, result.taskIdentifier ?? match.identifier, now);
212
+ // bind-task only returns memory + previousSession (not reviewTodosSection) —
213
+ // the auto-bind endpoint never built PR-review todos. Keep that scope here;
214
+ // surfacing review todos on the auto-bind path is a separate change.
215
+ const envelope = sessionContextEnvelope([card, result.memorySection]);
177
216
  if (envelope)
178
217
  lines.push(envelope);
179
218
  return lines;
@@ -207,6 +246,7 @@ async function postBindTask(sessionId, identifier, token, apiUrl) {
207
246
  taskIdentifier: body.taskIdentifier,
208
247
  taskTitle: body.taskTitle,
209
248
  memorySection: body.memorySection,
249
+ previousSession: body.previousSession ?? undefined,
210
250
  };
211
251
  }
212
252
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",