@lumoai/cli 1.6.0 → 1.7.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.
@@ -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,7 @@ 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");
11
14
  /**
12
15
  * Hard timeout for the hook POST. On timeout the request is aborted,
13
16
  * logged, and `runHook` exits 0 — Claude Code is never blocked beyond
@@ -99,21 +102,121 @@ function formatHookStdoutLines(path, responseBody) {
99
102
  lines.push(`[Lumo] session_id=${sessionId} | 当前任务: ${tb.taskIdentifier} - ${(0, sanitize_1.sanitizeField)(tb.taskTitle ?? '')}`);
100
103
  }
101
104
  else if (tb && tb.bound === false) {
102
- lines.push(`[Lumo] session_id=${sessionId} | 当前未绑定任务。请告诉我你要处理的任务编号(如 LUM-42),或说"跳过"。`);
105
+ lines.push(unboundPromptLine(sessionId));
103
106
  }
104
107
  // Memory + PR-review todos share one additionalContext block so Claude Code
105
108
  // 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
- }));
109
+ const envelope = sessionContextEnvelope([
110
+ body.memorySection,
111
+ body.reviewTodosSection,
112
+ ]);
113
+ if (envelope)
114
+ lines.push(envelope);
115
+ return lines;
116
+ }
117
+ /**
118
+ * The plain-text line shown when a session starts without a bound task and
119
+ * no task could be inferred from local git — the user is asked to name one.
120
+ */
121
+ function unboundPromptLine(sessionId) {
122
+ return `[Lumo] session_id=${sessionId} | 当前未绑定任务。请告诉我你要处理的任务编号(如 LUM-42),或说"跳过"。`;
123
+ }
124
+ /**
125
+ * Wrap any non-empty context parts into a single SessionStart
126
+ * hookSpecificOutput envelope so Claude Code injects one coherent
127
+ * additionalContext payload. Returns null when there is nothing to inject.
128
+ */
129
+ function sessionContextEnvelope(parts) {
130
+ const filled = parts.filter((s) => typeof s === 'string' && s !== '');
131
+ if (filled.length === 0)
132
+ return null;
133
+ return JSON.stringify({
134
+ hookSpecificOutput: {
135
+ hookEventName: 'SessionStart',
136
+ additionalContext: filled.join('\n\n'),
137
+ },
138
+ });
139
+ }
140
+ /**
141
+ * The single status line printed after a successful auto-bind. Explains which
142
+ * git signal (branch name vs recent commit) it relied on and how to undo it.
143
+ */
144
+ function formatAutoBindLine(sessionId, match, result) {
145
+ const basis = match.source === 'branch' ? '分支名' : '最近 commit';
146
+ const identifier = result.taskIdentifier ?? match.identifier;
147
+ return `[Lumo] session_id=${sessionId} | 已自动绑定 ${identifier} - ${(0, sanitize_1.sanitizeField)(result.taskTitle ?? '')}(依据${basis})。如果不对,回复"不是"我就帮你解绑。`;
148
+ }
149
+ /**
150
+ * Build the stdout lines for a session-start response, including the LUM-145
151
+ * auto-bind behavior: when the server reports no binding, infer the task from
152
+ * local git and bind it; on a hit, print the auto-bind line (plus the freshly
153
+ * bound task's memory). Falls back to the unbound prompt when nothing matches
154
+ * or the bind fails. Bound sessions reuse the existing formatting.
155
+ */
156
+ async function resolveSessionStartStdout(responseBody, deps) {
157
+ if (responseBody == null || typeof responseBody !== 'object')
158
+ return [];
159
+ const body = responseBody;
160
+ if (!body.sessionId)
161
+ return [];
162
+ const sessionId = body.sessionId;
163
+ const tb = body.taskBinding;
164
+ if (tb && tb.bound === true) {
165
+ return formatHookStdoutLines('session-start', responseBody);
114
166
  }
167
+ if (!tb || tb.bound !== false)
168
+ return [];
169
+ const match = deps.extractTask();
170
+ if (!match)
171
+ return [unboundPromptLine(sessionId)];
172
+ const result = await deps.bindTask(sessionId, match.identifier);
173
+ if (!result.ok)
174
+ return [unboundPromptLine(sessionId)];
175
+ const lines = [formatAutoBindLine(sessionId, match, result)];
176
+ const envelope = sessionContextEnvelope([result.memorySection]);
177
+ if (envelope)
178
+ lines.push(envelope);
115
179
  return lines;
116
180
  }
181
+ /**
182
+ * POST the inferred task binding to the same `bind-task` endpoint that
183
+ * `lumo session attach` uses. Guarded by the same short timeout as the hook
184
+ * POST so the extra round trip can never make session-start hang. Any
185
+ * failure (404 unknown task, network, timeout, non-2xx) returns
186
+ * `{ ok: false }`, which routes the caller back to the unbound prompt.
187
+ */
188
+ async function postBindTask(sessionId, identifier, token, apiUrl) {
189
+ const controller = new AbortController();
190
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
191
+ try {
192
+ const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}/bind-task`;
193
+ const res = await fetch(url, {
194
+ method: 'POST',
195
+ headers: {
196
+ 'Content-Type': 'application/json',
197
+ Authorization: `Bearer ${token}`,
198
+ },
199
+ body: JSON.stringify({ taskIdentifier: identifier }),
200
+ signal: controller.signal,
201
+ });
202
+ if (!res.ok)
203
+ return { ok: false };
204
+ const body = (await res.json());
205
+ return {
206
+ ok: true,
207
+ taskIdentifier: body.taskIdentifier,
208
+ taskTitle: body.taskTitle,
209
+ memorySection: body.memorySection,
210
+ };
211
+ }
212
+ catch (err) {
213
+ (0, hook_log_1.logHookError)('[session-start] auto-bind', err);
214
+ return { ok: false };
215
+ }
216
+ finally {
217
+ clearTimeout(timer);
218
+ }
219
+ }
117
220
  /**
118
221
  * POST the hook body to /api/hooks/<path> with a short timeout. All errors
119
222
  * — credential missing, network failure, timeout, non-2xx — are routed to
@@ -174,12 +277,19 @@ async function runHookWithBody(path, body, agentToken) {
174
277
  }
175
278
  else if (path === 'session-start' || path === 'pre-tool-use') {
176
279
  // Paths that turn the response body into stdout for Claude Code:
177
- // session-start → bind status + injected context
280
+ // session-start → bind status + injected context (incl. LUM-145
281
+ // auto-bind from local git when unbound)
178
282
  // pre-tool-use → parallel-edit collision warning (LUM-150 ③)
179
283
  // Only after a 2xx so a transient server failure emits nothing.
180
284
  try {
181
285
  const responseBody = await res.json();
182
- for (const line of formatHookStdoutLines(path, responseBody)) {
286
+ const lines = path === 'session-start'
287
+ ? await resolveSessionStartStdout(responseBody, {
288
+ extractTask: () => (0, git_task_1.extractTaskFromGit)(),
289
+ bindTask: (sessionId, identifier) => postBindTask(sessionId, identifier, creds.token, apiUrl),
290
+ })
291
+ : formatHookStdoutLines(path, responseBody);
292
+ for (const line of lines) {
183
293
  process.stdout.write(line + '\n');
184
294
  }
185
295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",