@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 +21 -0
- package/dist/cli/src/lib/git-task.js +58 -0
- package/dist/cli/src/lib/hook-runner.js +121 -11
- package/package.json +1 -1
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(
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
}
|