@lumoai/cli 1.6.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.
package/assets/skill.md CHANGED
@@ -1102,6 +1102,27 @@ across multiple tasks → consider `lumo memory promote`.
1102
1102
 
1103
1103
  ## Session Management
1104
1104
 
1105
+ ### Auto-bind at session start (from local git)
1106
+
1107
+ When a session starts **without** a bound task, the `session-start` hook tries to
1108
+ infer the task from local git before falling back to the "请告诉我任务编号" prompt:
1109
+
1110
+ - It reads the **current branch name** first (e.g. `lumo/LUM-145-...`), then the
1111
+ **most recent commit subjects** (e.g. `... [LUM-145]`), extracting the first
1112
+ `LUM-<n>`.
1113
+ - On a hit it binds the session to that task automatically (same `bind-task`
1114
+ endpoint as `session attach`) and prints a single line:
1115
+ `已自动绑定 LUM-145 - <title>(依据分支名/最近 commit)。如果不对,回复"不是"我就帮你解绑。`
1116
+ The freshly-bound task's memory is injected too.
1117
+ - No match (detached HEAD, a non-lumo branch with no tagged commits, not a git
1118
+ repo) or a failed bind (unknown task) → it degrades silently to the normal
1119
+ unbound prompt.
1120
+
1121
+ **Agent guidance:** if the user responds "不是" / "不对" / "wrong task" to an
1122
+ auto-bind line, run `lumo session detach` to clear the binding (then `session
1123
+ attach <LUM-N>` if they name the right one). No detach is needed when the
1124
+ auto-bound task is correct.
1125
+
1105
1126
  ### `lumo session attach <identifier>` — bind the current session to a task
1106
1127
 
1107
1128
  Use this whenever the user mentions a task ID. The command is the only way to bind a session to a task.
@@ -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
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.matchTaskIdentifier = matchTaskIdentifier;
4
+ exports.extractTaskFromGit = extractTaskFromGit;
5
+ const child_process_1 = require("child_process");
6
+ /**
7
+ * How many recent commit subjects to scan when the branch name carries no
8
+ * task id (e.g. on a generic feature branch or detached HEAD).
9
+ */
10
+ const DEFAULT_COMMIT_DEPTH = 20;
11
+ const TASK_RE = /LUM-(\d+)/i;
12
+ /**
13
+ * Pull the first `LUM-<n>` token out of arbitrary text (a branch name or a
14
+ * commit subject) and normalize it to upper case. Returns null when no task
15
+ * id is present. The leading `-` in the pattern means the bare word `lumo`
16
+ * never matches.
17
+ */
18
+ function matchTaskIdentifier(text) {
19
+ const m = text.match(TASK_RE);
20
+ return m ? `LUM-${m[1]}` : null;
21
+ }
22
+ /**
23
+ * Run a git subcommand and return trimmed stdout, or '' on any failure
24
+ * (non-zero exit, spawn error, not a repository, timeout). Never throws.
25
+ */
26
+ function gitOutput(args, cwd) {
27
+ try {
28
+ const r = (0, child_process_1.spawnSync)('git', args, {
29
+ cwd,
30
+ encoding: 'utf8',
31
+ timeout: 2000,
32
+ });
33
+ if (r.error || r.status !== 0)
34
+ return '';
35
+ return (r.stdout ?? '').trim();
36
+ }
37
+ catch {
38
+ return '';
39
+ }
40
+ }
41
+ /**
42
+ * Infer the task to work on from local git: prefer the current branch name
43
+ * (e.g. `lumo/LUM-145-...`), then fall back to the most recent commit
44
+ * subjects (e.g. `... [LUM-145]`). Returns null when nothing matches —
45
+ * detached HEAD (branch reads `HEAD`), a non-lumo branch with no tagged
46
+ * commits, or a directory that is not a git repository all degrade to null.
47
+ */
48
+ function extractTaskFromGit(cwd, commitDepth = DEFAULT_COMMIT_DEPTH) {
49
+ const branch = gitOutput(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
50
+ const fromBranch = matchTaskIdentifier(branch);
51
+ if (fromBranch)
52
+ return { identifier: fromBranch, source: 'branch' };
53
+ const log = gitOutput(['log', '-n', String(commitDepth), '--format=%s'], cwd);
54
+ const fromLog = matchTaskIdentifier(log);
55
+ if (fromLog)
56
+ return { identifier: fromLog, source: 'commit' };
57
+ return null;
58
+ }
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatHookStdoutLines = formatHookStdoutLines;
4
+ exports.formatAutoBindLine = formatAutoBindLine;
5
+ exports.resolveSessionStartStdout = resolveSessionStartStdout;
4
6
  exports.runHook = runHook;
5
7
  exports.runHookWithBody = runHookWithBody;
6
8
  const config_1 = require("./config");
@@ -8,6 +10,8 @@ const api_1 = require("./api");
8
10
  const hook_log_1 = require("./hook-log");
9
11
  const sanitize_1 = require("./sanitize");
10
12
  const agent_1 = require("./agent");
13
+ const git_task_1 = require("./git-task");
14
+ const format_1 = require("./format");
11
15
  /**
12
16
  * Hard timeout for the hook POST. On timeout the request is aborted,
13
17
  * logged, and `runHook` exits 0 — Claude Code is never blocked beyond
@@ -66,7 +70,7 @@ function readStdin() {
66
70
  * The JSON lines conform to Claude Code's hookSpecificOutput envelope so the
67
71
  * runtime injects additionalContext into the conversation automatically.
68
72
  */
69
- function formatHookStdoutLines(path, responseBody) {
73
+ function formatHookStdoutLines(path, responseBody, now = new Date()) {
70
74
  if (path === 'pre-tool-use') {
71
75
  if (responseBody == null || typeof responseBody !== 'object')
72
76
  return [];
@@ -99,21 +103,160 @@ function formatHookStdoutLines(path, responseBody) {
99
103
  lines.push(`[Lumo] session_id=${sessionId} | 当前任务: ${tb.taskIdentifier} - ${(0, sanitize_1.sanitizeField)(tb.taskTitle ?? '')}`);
100
104
  }
101
105
  else if (tb && tb.bound === false) {
102
- lines.push(`[Lumo] session_id=${sessionId} | 当前未绑定任务。请告诉我你要处理的任务编号(如 LUM-42),或说"跳过"。`);
106
+ lines.push(unboundPromptLine(sessionId));
103
107
  }
104
- // Memory + PR-review todos share one additionalContext block so Claude Code
105
- // injects a single coherent context payload at session start.
106
- const contextParts = [body.memorySection, body.reviewTodosSection].filter((s) => typeof s === 'string' && s !== '');
107
- if (contextParts.length > 0) {
108
- lines.push(JSON.stringify({
109
- hookSpecificOutput: {
110
- hookEventName: 'SessionStart',
111
- additionalContext: contextParts.join('\n\n'),
112
- },
113
- }));
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);
112
+ const envelope = sessionContextEnvelope([
113
+ card,
114
+ body.memorySection,
115
+ body.reviewTodosSection,
116
+ ]);
117
+ if (envelope)
118
+ lines.push(envelope);
119
+ return lines;
120
+ }
121
+ /**
122
+ * The plain-text line shown when a session starts without a bound task and
123
+ * no task could be inferred from local git — the user is asked to name one.
124
+ */
125
+ function unboundPromptLine(sessionId) {
126
+ return `[Lumo] session_id=${sessionId} | 当前未绑定任务。请告诉我你要处理的任务编号(如 LUM-42),或说"跳过"。`;
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;
114
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
+ }
159
+ /**
160
+ * Wrap any non-empty context parts into a single SessionStart
161
+ * hookSpecificOutput envelope so Claude Code injects one coherent
162
+ * additionalContext payload. Returns null when there is nothing to inject.
163
+ */
164
+ function sessionContextEnvelope(parts) {
165
+ const filled = parts.filter((s) => typeof s === 'string' && s !== '');
166
+ if (filled.length === 0)
167
+ return null;
168
+ return JSON.stringify({
169
+ hookSpecificOutput: {
170
+ hookEventName: 'SessionStart',
171
+ additionalContext: filled.join('\n\n'),
172
+ },
173
+ });
174
+ }
175
+ /**
176
+ * The single status line printed after a successful auto-bind. Explains which
177
+ * git signal (branch name vs recent commit) it relied on and how to undo it.
178
+ */
179
+ function formatAutoBindLine(sessionId, match, result) {
180
+ const basis = match.source === 'branch' ? '分支名' : '最近 commit';
181
+ const identifier = result.taskIdentifier ?? match.identifier;
182
+ return `[Lumo] session_id=${sessionId} | 已自动绑定 ${identifier} - ${(0, sanitize_1.sanitizeField)(result.taskTitle ?? '')}(依据${basis})。如果不对,回复"不是"我就帮你解绑。`;
183
+ }
184
+ /**
185
+ * Build the stdout lines for a session-start response, including the LUM-145
186
+ * auto-bind behavior: when the server reports no binding, infer the task from
187
+ * local git and bind it; on a hit, print the auto-bind line (plus the freshly
188
+ * bound task's memory). Falls back to the unbound prompt when nothing matches
189
+ * or the bind fails. Bound sessions reuse the existing formatting.
190
+ */
191
+ async function resolveSessionStartStdout(responseBody, deps, now = new Date()) {
192
+ if (responseBody == null || typeof responseBody !== 'object')
193
+ return [];
194
+ const body = responseBody;
195
+ if (!body.sessionId)
196
+ return [];
197
+ const sessionId = body.sessionId;
198
+ const tb = body.taskBinding;
199
+ if (tb && tb.bound === true) {
200
+ return formatHookStdoutLines('session-start', responseBody, now);
201
+ }
202
+ if (!tb || tb.bound !== false)
203
+ return [];
204
+ const match = deps.extractTask();
205
+ if (!match)
206
+ return [unboundPromptLine(sessionId)];
207
+ const result = await deps.bindTask(sessionId, match.identifier);
208
+ if (!result.ok)
209
+ return [unboundPromptLine(sessionId)];
210
+ const lines = [formatAutoBindLine(sessionId, match, result)];
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]);
216
+ if (envelope)
217
+ lines.push(envelope);
115
218
  return lines;
116
219
  }
220
+ /**
221
+ * POST the inferred task binding to the same `bind-task` endpoint that
222
+ * `lumo session attach` uses. Guarded by the same short timeout as the hook
223
+ * POST so the extra round trip can never make session-start hang. Any
224
+ * failure (404 unknown task, network, timeout, non-2xx) returns
225
+ * `{ ok: false }`, which routes the caller back to the unbound prompt.
226
+ */
227
+ async function postBindTask(sessionId, identifier, token, apiUrl) {
228
+ const controller = new AbortController();
229
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
230
+ try {
231
+ const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}/bind-task`;
232
+ const res = await fetch(url, {
233
+ method: 'POST',
234
+ headers: {
235
+ 'Content-Type': 'application/json',
236
+ Authorization: `Bearer ${token}`,
237
+ },
238
+ body: JSON.stringify({ taskIdentifier: identifier }),
239
+ signal: controller.signal,
240
+ });
241
+ if (!res.ok)
242
+ return { ok: false };
243
+ const body = (await res.json());
244
+ return {
245
+ ok: true,
246
+ taskIdentifier: body.taskIdentifier,
247
+ taskTitle: body.taskTitle,
248
+ memorySection: body.memorySection,
249
+ previousSession: body.previousSession ?? undefined,
250
+ };
251
+ }
252
+ catch (err) {
253
+ (0, hook_log_1.logHookError)('[session-start] auto-bind', err);
254
+ return { ok: false };
255
+ }
256
+ finally {
257
+ clearTimeout(timer);
258
+ }
259
+ }
117
260
  /**
118
261
  * POST the hook body to /api/hooks/<path> with a short timeout. All errors
119
262
  * — credential missing, network failure, timeout, non-2xx — are routed to
@@ -174,12 +317,19 @@ async function runHookWithBody(path, body, agentToken) {
174
317
  }
175
318
  else if (path === 'session-start' || path === 'pre-tool-use') {
176
319
  // Paths that turn the response body into stdout for Claude Code:
177
- // session-start → bind status + injected context
320
+ // session-start → bind status + injected context (incl. LUM-145
321
+ // auto-bind from local git when unbound)
178
322
  // pre-tool-use → parallel-edit collision warning (LUM-150 ③)
179
323
  // Only after a 2xx so a transient server failure emits nothing.
180
324
  try {
181
325
  const responseBody = await res.json();
182
- for (const line of formatHookStdoutLines(path, responseBody)) {
326
+ const lines = path === 'session-start'
327
+ ? await resolveSessionStartStdout(responseBody, {
328
+ extractTask: () => (0, git_task_1.extractTaskFromGit)(),
329
+ bindTask: (sessionId, identifier) => postBindTask(sessionId, identifier, creds.token, apiUrl),
330
+ })
331
+ : formatHookStdoutLines(path, responseBody);
332
+ for (const line of lines) {
183
333
  process.stdout.write(line + '\n');
184
334
  }
185
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.6.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",