@lumoai/cli 1.5.1 → 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 +52 -2
- package/dist/cli/src/commands/session-wrap.js +32 -0
- package/dist/cli/src/commands/task-context.js +5 -0
- package/dist/cli/src/commands/wrap/progress-comment-section.js +81 -0
- package/dist/cli/src/index.js +7 -0
- package/dist/cli/src/lib/editor.js +66 -0
- package/dist/cli/src/lib/git-task.js +58 -0
- package/dist/cli/src/lib/hook-runner.js +155 -16
- package/dist/cli/src/lib/progress-comment-api.js +47 -0
- package/dist/cli/src/lib/wrap-panel.js +15 -0
- package/package.json +1 -1
package/assets/skill.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lumo
|
|
3
|
-
description: 'Use the Lumo CLI to load task context, manage session bindings, inspect projects and milestones, and create/update/list/show/comment on tasks from the terminal. Activate when: user mentions a Lumo task identifier (LUM-42, LUM-12, etc.), asks to load task background or context, wants to bind, check, or detach a Claude Code session''s task binding, is about to start development work on a specific task, wants to create a new task, list their tasks, view a task, comment on a task, list projects, list milestones, attach a task to a milestone, or update a task''s status/title/description/priority/assignee/milestone. Triggers on: "LUM-", "task context", "load context", "session start", "session attach", "session status", "session detach", "bind session", "unbind session", "which task", "what task am I on", "work on LUM", "create task", "new task", "add task", "file a task", "log a task", "list tasks", "my tasks", "show task", "view task", "comment on task", "leave a comment", "list projects", "what projects", "update task", "change task status", "rename task", "reassign task", "mark task as done", "milestone", "里程碑", "list milestones", "set milestone", "挂到 milestone", "attach milestone", "unbind milestone", "create milestone", "new milestone", "update milestone", "change milestone status", "delete milestone", "show milestone", "view milestone", "tasks in milestone", "milestone tasks", "新建里程碑", "更新里程碑", "删除里程碑", "查看里程碑", "auth login", "log in", "login", "auth logout", "log out", "logout", "sign out", "switch account", "switch identity", "whoami", "who am I", "current identity", "current user", "current workspace", "登录", "登出", "切换账号", "当前身份", "create doc", "new doc", "new document", "write doc", "写文档", "新建文档", "update doc", "edit doc", "修改文档", "更新文档", "list docs", "my docs", "我的文档", "show doc", "view doc", "查看文档", "delete doc", "删除文档", "bind doc", "attach doc to task", "把文档关联到任务", "文档绑定到任务", "unbind doc", "解绑文档", "personal doc", "workspace doc", "个人文档", "workspace 文档", "doc scope", "tag", "add tag", "remove tag", "标签", "添加标签", "移除标签", "doc tag", "task tag", "share doc", "doc share", "share document", "分享文档", "文档分享", "unshare doc", "remove share", "取消分享", "share list", "list doc shares", "who has access", "viewer", "editor", "manager", "shared with", "doc tree", "doc move", "move doc", "reparent doc", "文档树", "移动文档", "sprint", "start sprint", "close sprint", "add to sprint", "active sprints", "冲刺", "迭代", "开始冲刺", "关闭冲刺", "create sprint", "new sprint", "list sprints", "show sprint", "update sprint", "delete sprint", "sprint summary", "sprint retro", "把任务挂到冲刺", "冲刺里有什么", "lumo update", "update cli", "upgrade lumo", "update lumo", "upgrade cli", "升级 lumo", "更新 cli", "new lumo version", "是否有新版本", "lumo setup", "install lumo skill", "install lumo hooks", "wire up lumo", "set up lumo", "onboard lumo", "npx @lumoai/cli", "安装 lumo", "配置 lumo", "lumo 初始化", "task artifact", "artifact add", "artifact list", "artifact show", "artifact rm", "artifact delete", "artifact update", "update artifact", "edit artifact", "change artifact kind", "change artifact source", "remove artifact", "delete artifact", "spec artifact", "record spec", "attach spec", "attach plan", "记录 spec", "挂 spec", "查看 artifact", "编辑 artifact", "修改 artifact", "删除 artifact", figma, attach figma, figma link, 关联 figma, 设计稿, figma design, "memory", "记忆", "remember", "record a memory", "记一条", "promote memory", "promote to project", "沉淀", "task memory", "project memory", "lumo memory", "retrieval", "取全文", "load full content", "拉全文", "task slack show", "看 thread", "看 slack thread", "show slack thread", "slack 全文", "task web show", "show web link body", "web 正文", "抓网页正文", "task figma context", "figma metadata", "figma 元数据", "figma 设计上下文", "task comments list", "list comments", "看评论", "评论流", "task pr show", "查看 PR", "show pr", "PR 详情", "pr metadata", "import google doc", "sync google doc", "google drive", "doc import-gdoc", "doc sync", "导入 google 文档", "同步 google 文档".'
|
|
3
|
+
description: 'Use the Lumo CLI to load task context, manage session bindings, inspect projects and milestones, and create/update/list/show/comment on tasks from the terminal. Activate when: user mentions a Lumo task identifier (LUM-42, LUM-12, etc.), asks to load task background or context, wants to bind, check, or detach a Claude Code session''s task binding, is about to start development work on a specific task, wants to create a new task, list their tasks, view a task, comment on a task, list projects, list milestones, attach a task to a milestone, or update a task''s status/title/description/priority/assignee/milestone. Triggers on: "LUM-", "task context", "load context", "session start", "session attach", "session status", "session detach", "bind session", "unbind session", "which task", "what task am I on", "work on LUM", "create task", "new task", "add task", "file a task", "log a task", "list tasks", "my tasks", "show task", "view task", "comment on task", "leave a comment", "list projects", "what projects", "update task", "change task status", "rename task", "reassign task", "mark task as done", "milestone", "里程碑", "list milestones", "set milestone", "挂到 milestone", "attach milestone", "unbind milestone", "create milestone", "new milestone", "update milestone", "change milestone status", "delete milestone", "show milestone", "view milestone", "tasks in milestone", "milestone tasks", "新建里程碑", "更新里程碑", "删除里程碑", "查看里程碑", "auth login", "log in", "login", "auth logout", "log out", "logout", "sign out", "switch account", "switch identity", "whoami", "who am I", "current identity", "current user", "current workspace", "登录", "登出", "切换账号", "当前身份", "create doc", "new doc", "new document", "write doc", "写文档", "新建文档", "update doc", "edit doc", "修改文档", "更新文档", "list docs", "my docs", "我的文档", "show doc", "view doc", "查看文档", "delete doc", "删除文档", "bind doc", "attach doc to task", "把文档关联到任务", "文档绑定到任务", "unbind doc", "解绑文档", "personal doc", "workspace doc", "个人文档", "workspace 文档", "doc scope", "tag", "add tag", "remove tag", "标签", "添加标签", "移除标签", "doc tag", "task tag", "share doc", "doc share", "share document", "分享文档", "文档分享", "unshare doc", "remove share", "取消分享", "share list", "list doc shares", "who has access", "viewer", "editor", "manager", "shared with", "doc tree", "doc move", "move doc", "reparent doc", "文档树", "移动文档", "sprint", "start sprint", "close sprint", "add to sprint", "active sprints", "冲刺", "迭代", "开始冲刺", "关闭冲刺", "create sprint", "new sprint", "list sprints", "show sprint", "update sprint", "delete sprint", "sprint summary", "sprint retro", "把任务挂到冲刺", "冲刺里有什么", "lumo update", "update cli", "upgrade lumo", "update lumo", "upgrade cli", "升级 lumo", "更新 cli", "new lumo version", "是否有新版本", "lumo setup", "install lumo skill", "install lumo hooks", "wire up lumo", "set up lumo", "onboard lumo", "npx @lumoai/cli", "安装 lumo", "配置 lumo", "lumo 初始化", "task artifact", "artifact add", "artifact list", "artifact show", "artifact rm", "artifact delete", "artifact update", "update artifact", "edit artifact", "change artifact kind", "change artifact source", "remove artifact", "delete artifact", "spec artifact", "record spec", "attach spec", "attach plan", "记录 spec", "挂 spec", "查看 artifact", "编辑 artifact", "修改 artifact", "删除 artifact", figma, attach figma, figma link, 关联 figma, 设计稿, figma design, "memory", "记忆", "remember", "record a memory", "记一条", "promote memory", "promote to project", "沉淀", "task memory", "project memory", "lumo memory", "retrieval", "取全文", "load full content", "拉全文", "task slack show", "看 thread", "看 slack thread", "show slack thread", "slack 全文", "task web show", "show web link body", "web 正文", "抓网页正文", "task figma context", "figma metadata", "figma 元数据", "figma 设计上下文", "task comments list", "list comments", "看评论", "评论流", "task pr show", "查看 PR", "show pr", "PR 详情", "pr metadata", "import google doc", "sync google doc", "google drive", "doc import-gdoc", "doc sync", "导入 google 文档", "同步 google 文档", "session wrap", "wrap up session", "收尾", "post progress", "把进度发出去", "progress comment", "进度评论".'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Prerequisites
|
|
@@ -122,13 +122,16 @@ The command prints a markdown document to stdout containing:
|
|
|
122
122
|
|
|
123
123
|
1. **Task header** — identifier, title, status, description
|
|
124
124
|
2. **Memory section** — cross-session learnings accumulated over prior sessions; treat as trusted background context
|
|
125
|
-
3. **
|
|
125
|
+
3. **Inline source cards** — Slack / web / Figma / artifacts / documents / comments / Pull Requests (see "Context Retrieval" below)
|
|
126
|
+
4. **PR Review 待办** — mirrored PR review comments as a checkbox todo list: each line-level reviewer comment (shown as `` `file:line` `` + the reviewer's ask + a link to the GitHub comment) and each `changes_requested` review summary (shown as "🛑 整体要求改动"). Present only when the task's PR(s) have review comments. This same block is **auto-injected at session start** (alongside the memory section) when the session is bound to a task — so reviewer asks surface without re-running `task context`.
|
|
127
|
+
5. **Previous sessions** — ordered newest-first, each with:
|
|
126
128
|
- A headline summary of what was done
|
|
127
129
|
- Unresolved items (carry-over TODOs from that session)
|
|
128
130
|
|
|
129
131
|
### How to use the context
|
|
130
132
|
|
|
131
133
|
- **Unresolved items** from the most recent session are the highest-priority carry-overs — address them before starting new work unless the user says otherwise
|
|
134
|
+
- **PR Review 待办** items are reviewer-requested changes — treat each unchecked box as a TODO to resolve, then reply on the PR (a Lumo comment mirrors back to GitHub)
|
|
132
135
|
- **Memory section** provides validated context that persists across sessions — use it to avoid re-learning decisions or constraints
|
|
133
136
|
- Focus on the **most recent 1–2 sessions** for relevant state; older sessions are for historical reference only
|
|
134
137
|
- If there are **no prior sessions**, this is a fresh start — read the task description carefully and ask clarifying questions if needed
|
|
@@ -1099,6 +1102,27 @@ across multiple tasks → consider `lumo memory promote`.
|
|
|
1099
1102
|
|
|
1100
1103
|
## Session Management
|
|
1101
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
|
+
|
|
1102
1126
|
### `lumo session attach <identifier>` — bind the current session to a task
|
|
1103
1127
|
|
|
1104
1128
|
Use this whenever the user mentions a task ID. The command is the only way to bind a session to a task.
|
|
@@ -1139,6 +1163,32 @@ lumo session detach
|
|
|
1139
1163
|
|
|
1140
1164
|
When to suggest: the user wants to stop tagging the current session with the active task (e.g., switching to unrelated exploratory work without binding to a different task).
|
|
1141
1165
|
|
|
1166
|
+
### `lumo session wrap [--yes] [--dry-run]` — draft + post a progress comment at wrap-up
|
|
1167
|
+
|
|
1168
|
+
Session-end wrap-up panel. Reads back the current Claude Code session's per-turn
|
|
1169
|
+
`turnSummary` rows (the one-line Chinese summaries written each STOP), aggregates
|
|
1170
|
+
every turn **since the last progress comment** into one bulleted body, and — after
|
|
1171
|
+
a `[y] 发送 / [e] 编辑 / [s] 跳过` confirmation — posts it as a comment on the
|
|
1172
|
+
session's bound task. A server-side watermark (`Session.lastProgressCommentAt`)
|
|
1173
|
+
means re-running never re-posts the same turns.
|
|
1174
|
+
|
|
1175
|
+
```bash
|
|
1176
|
+
lumo session wrap # interactive: preview draft, choose y / e / s
|
|
1177
|
+
lumo session wrap --yes # post the drafted body without prompting (agent-friendly)
|
|
1178
|
+
lumo session wrap --dry-run # print the draft only; never posts, never advances watermark
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
- Requires `$CLAUDE_CODE_SESSION_ID` (must run inside Claude Code) and a bound
|
|
1182
|
+
task (`lumo session attach <LUM-N>` first). With no bound task or no new turn
|
|
1183
|
+
summaries, the panel prints "(无内容)" and posts nothing.
|
|
1184
|
+
- `[e] 编辑` opens `$EDITOR` (fallback vi/nano) on the drafted body; the edited
|
|
1185
|
+
text is posted and the watermark still advances to the turns the draft covered.
|
|
1186
|
+
- Non-TTY without `--yes`: prints the draft and does **not** post (safe default).
|
|
1187
|
+
|
|
1188
|
+
When to suggest: at the end of a working session on a bound task, to record what
|
|
1189
|
+
was done as a progress comment — offer `lumo session wrap` rather than composing
|
|
1190
|
+
a `task comment` by hand.
|
|
1191
|
+
|
|
1142
1192
|
### When to suggest session binding
|
|
1143
1193
|
|
|
1144
1194
|
- If the user mentions a task ID (e.g., "let's work on LUM-42") and no session is currently bound, **suggest running `lumo session attach`**.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sessionWrap = sessionWrap;
|
|
4
|
+
const config_1 = require("../lib/config");
|
|
5
|
+
const wrap_panel_1 = require("../lib/wrap-panel");
|
|
6
|
+
const progress_comment_section_1 = require("./wrap/progress-comment-section");
|
|
7
|
+
/**
|
|
8
|
+
* `lumo session wrap [--yes] [--dry-run]`
|
|
9
|
+
*
|
|
10
|
+
* Session-end wrap-up panel. v1 has a single section: draft a progress comment
|
|
11
|
+
* from this session's unposted turnSummaries and post it (after y/e/s
|
|
12
|
+
* confirmation) to the bound task. Designed as a multi-section panel so
|
|
13
|
+
* LUM-152's memory-review section slots in without touching this command.
|
|
14
|
+
*/
|
|
15
|
+
async function sessionWrap(options) {
|
|
16
|
+
const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
|
|
17
|
+
if (!sessionId) {
|
|
18
|
+
console.error('Error: $CLAUDE_CODE_SESSION_ID is not set.\n' +
|
|
19
|
+
'`lumo session wrap` must be run inside a Claude Code session.');
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
const creds = (0, config_1.readCredentials)();
|
|
23
|
+
if (!creds) {
|
|
24
|
+
console.error('Error: not logged in. Run `lumo auth login` first.');
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
const sections = [new progress_comment_section_1.ProgressCommentSection({ creds, sessionId })];
|
|
28
|
+
await (0, wrap_panel_1.runWrapPanel)(sections, {
|
|
29
|
+
yes: options.yes === true,
|
|
30
|
+
dryRun: options.dryRun === true,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -99,6 +99,11 @@ function formatTaskContextMarkdown(data, now) {
|
|
|
99
99
|
lines.push((0, sanitize_1.sanitizeField)(data.prSection.trimEnd()));
|
|
100
100
|
lines.push('');
|
|
101
101
|
}
|
|
102
|
+
if (data.prReviewTodosSection &&
|
|
103
|
+
data.prReviewTodosSection.trim().length > 0) {
|
|
104
|
+
lines.push((0, sanitize_1.sanitizeField)(data.prReviewTodosSection.trimEnd()));
|
|
105
|
+
lines.push('');
|
|
106
|
+
}
|
|
102
107
|
if (data.sessions.length === 0) {
|
|
103
108
|
lines.push('## Previous Sessions (0)');
|
|
104
109
|
lines.push('');
|
|
@@ -0,0 +1,81 @@
|
|
|
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 = '本次会话进度';
|
|
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 = '进度评论';
|
|
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(`将发到 ${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,未发送)\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] 发送 [e] 编辑 [s] 跳过 > ')).toLowerCase();
|
|
51
|
+
if (choice === 's' || choice === '') {
|
|
52
|
+
process.stdout.write('已跳过。\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('正文为空,已跳过。\n');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
process.stdout.write(`${(0, sanitize_1.sanitizeField)(edited)}\n`);
|
|
62
|
+
const confirm = (await (0, line_prompt_1.promptLine)('[y] 发送 [s] 跳过 > ')).toLowerCase();
|
|
63
|
+
if (confirm !== 'y') {
|
|
64
|
+
process.stdout.write('已跳过。\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('无法识别的选择,已跳过。\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(`已发送进度评论 (comment ${commentId})\n`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
exports.ProgressCommentSection = ProgressCommentSection;
|
package/dist/cli/src/index.js
CHANGED
|
@@ -45,6 +45,7 @@ const hook_1 = require("./commands/hook");
|
|
|
45
45
|
const session_attach_1 = require("./commands/session-attach");
|
|
46
46
|
const session_detach_1 = require("./commands/session-detach");
|
|
47
47
|
const session_status_1 = require("./commands/session-status");
|
|
48
|
+
const session_wrap_1 = require("./commands/session-wrap");
|
|
48
49
|
const task_context_1 = require("./commands/task-context");
|
|
49
50
|
const task_create_1 = require("./commands/task-create");
|
|
50
51
|
const task_update_1 = require("./commands/task-update");
|
|
@@ -195,6 +196,12 @@ session
|
|
|
195
196
|
.command('detach')
|
|
196
197
|
.description('Clear the task binding on the current Claude Code session. Past hook events keep their taskId; only future events become untagged.')
|
|
197
198
|
.action(wrap(() => (0, session_detach_1.sessionDetach)()));
|
|
199
|
+
session
|
|
200
|
+
.command('wrap')
|
|
201
|
+
.description("Session-end wrap-up: draft a progress comment from this session's turn summaries and post it to the bound task after confirmation.")
|
|
202
|
+
.option('-y, --yes', 'Post the drafted comment without prompting (agent-friendly)')
|
|
203
|
+
.option('--dry-run', 'Print the draft but do not post or advance the watermark')
|
|
204
|
+
.action(wrap(options => (0, session_wrap_1.sessionWrap)(options)));
|
|
198
205
|
const task = program
|
|
199
206
|
.command('task')
|
|
200
207
|
.description('Inspect tasks from the terminal');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.editInEditor = editInEditor;
|
|
37
|
+
const child_process_1 = require("child_process");
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const os = __importStar(require("os"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
/**
|
|
42
|
+
* Open `initial` in the user's $EDITOR (fallback vi → nano), return the edited
|
|
43
|
+
* text. Degrades gracefully: when stdin is not a TTY (piped / agent) or the
|
|
44
|
+
* editor exits non-zero, the original text is returned unchanged so the caller
|
|
45
|
+
* can proceed without an interactive editor.
|
|
46
|
+
*/
|
|
47
|
+
async function editInEditor(initial) {
|
|
48
|
+
if (!process.stdin.isTTY)
|
|
49
|
+
return initial;
|
|
50
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
|
|
51
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lumo-wrap-'));
|
|
52
|
+
const file = path.join(dir, 'progress.md');
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(file, initial, 'utf8');
|
|
55
|
+
const result = (0, child_process_1.spawnSync)(editor, [file], { stdio: 'inherit' });
|
|
56
|
+
if (result.status !== 0)
|
|
57
|
+
return initial;
|
|
58
|
+
return fs.readFileSync(file, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return initial;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -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
|
|
@@ -51,16 +54,40 @@ function readStdin() {
|
|
|
51
54
|
}
|
|
52
55
|
/**
|
|
53
56
|
* Build the array of stdout lines to emit for a given hook path + response
|
|
54
|
-
* body. Returns an empty array for any path
|
|
57
|
+
* body. Returns an empty array for any path that emits nothing.
|
|
55
58
|
*
|
|
56
59
|
* For 'session-start' the array contains:
|
|
57
60
|
* [0] plain-text bind/unbound status line (always present when taskBinding exists)
|
|
58
|
-
* [1] (optional) hookSpecificOutput JSON when
|
|
61
|
+
* [1] (optional) hookSpecificOutput JSON when there is any additionalContext —
|
|
62
|
+
* the memory section and the PR-review-todos section, concatenated.
|
|
59
63
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
64
|
+
* For 'pre-tool-use' the array contains:
|
|
65
|
+
* [0] (optional) a PreToolUse hookSpecificOutput JSON carrying the parallel-
|
|
66
|
+
* edit collision warning as additionalContext, when the server returned a
|
|
67
|
+
* `collisionWarning` (LUM-150 step ③). Empty otherwise.
|
|
68
|
+
*
|
|
69
|
+
* The JSON lines conform to Claude Code's hookSpecificOutput envelope so the
|
|
70
|
+
* runtime injects additionalContext into the conversation automatically.
|
|
62
71
|
*/
|
|
63
72
|
function formatHookStdoutLines(path, responseBody) {
|
|
73
|
+
if (path === 'pre-tool-use') {
|
|
74
|
+
if (responseBody == null || typeof responseBody !== 'object')
|
|
75
|
+
return [];
|
|
76
|
+
const warning = responseBody
|
|
77
|
+
.collisionWarning;
|
|
78
|
+
if (typeof warning !== 'string' || warning === '')
|
|
79
|
+
return [];
|
|
80
|
+
return [
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
hookSpecificOutput: {
|
|
83
|
+
hookEventName: 'PreToolUse',
|
|
84
|
+
// Server-built text routed back to stdout — sanitize untrusted free
|
|
85
|
+
// text before Claude Code consumes it (ANSI/control-char injection).
|
|
86
|
+
additionalContext: (0, sanitize_1.sanitizeField)(warning),
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
];
|
|
90
|
+
}
|
|
64
91
|
if (path !== 'session-start')
|
|
65
92
|
return [];
|
|
66
93
|
if (responseBody == null || typeof responseBody !== 'object')
|
|
@@ -75,18 +102,121 @@ function formatHookStdoutLines(path, responseBody) {
|
|
|
75
102
|
lines.push(`[Lumo] session_id=${sessionId} | 当前任务: ${tb.taskIdentifier} - ${(0, sanitize_1.sanitizeField)(tb.taskTitle ?? '')}`);
|
|
76
103
|
}
|
|
77
104
|
else if (tb && tb.bound === false) {
|
|
78
|
-
lines.push(
|
|
105
|
+
lines.push(unboundPromptLine(sessionId));
|
|
79
106
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
107
|
+
// Memory + PR-review todos share one additionalContext block so Claude Code
|
|
108
|
+
// injects a single coherent context payload at session start.
|
|
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);
|
|
87
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);
|
|
88
179
|
return lines;
|
|
89
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
|
+
}
|
|
90
220
|
/**
|
|
91
221
|
* POST the hook body to /api/hooks/<path> with a short timeout. All errors
|
|
92
222
|
* — credential missing, network failure, timeout, non-2xx — are routed to
|
|
@@ -145,12 +275,21 @@ async function runHookWithBody(path, body, agentToken) {
|
|
|
145
275
|
if (!res.ok) {
|
|
146
276
|
(0, hook_log_1.logHookError)(`[${path}]`, `HTTP ${res.status} from ${url}`);
|
|
147
277
|
}
|
|
148
|
-
else if (path === 'session-start') {
|
|
149
|
-
//
|
|
150
|
-
//
|
|
278
|
+
else if (path === 'session-start' || path === 'pre-tool-use') {
|
|
279
|
+
// Paths that turn the response body into stdout for Claude Code:
|
|
280
|
+
// session-start → bind status + injected context (incl. LUM-145
|
|
281
|
+
// auto-bind from local git when unbound)
|
|
282
|
+
// pre-tool-use → parallel-edit collision warning (LUM-150 ③)
|
|
283
|
+
// Only after a 2xx so a transient server failure emits nothing.
|
|
151
284
|
try {
|
|
152
285
|
const responseBody = await res.json();
|
|
153
|
-
|
|
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) {
|
|
154
293
|
process.stdout.write(line + '\n');
|
|
155
294
|
}
|
|
156
295
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchProgressDraft = fetchProgressDraft;
|
|
4
|
+
exports.postProgressComment = postProgressComment;
|
|
5
|
+
const api_1 = require("./api");
|
|
6
|
+
function base(creds) {
|
|
7
|
+
return (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
|
|
8
|
+
}
|
|
9
|
+
/** GET the unposted progress draft for the session. Throws on transport / non-200. */
|
|
10
|
+
async function fetchProgressDraft(creds, sessionId) {
|
|
11
|
+
const url = `${base(creds)}/api/sessions/${encodeURIComponent(sessionId)}/turn-summaries`;
|
|
12
|
+
const res = await fetch(url, {
|
|
13
|
+
headers: { Authorization: `Bearer ${creds.token}` },
|
|
14
|
+
});
|
|
15
|
+
if (res.status === 401)
|
|
16
|
+
throw new Error('API key invalid or revoked. Run `lumo auth login`.');
|
|
17
|
+
if (!res.ok)
|
|
18
|
+
throw new Error(`progress draft fetch failed (HTTP ${res.status})`);
|
|
19
|
+
return (await res.json());
|
|
20
|
+
}
|
|
21
|
+
/** POST the (possibly edited) body + watermark. Throws the server message on non-201. */
|
|
22
|
+
async function postProgressComment(creds, sessionId, payload) {
|
|
23
|
+
const url = `${base(creds)}/api/sessions/${encodeURIComponent(sessionId)}/progress-comment`;
|
|
24
|
+
const res = await fetch(url, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
Authorization: `Bearer ${creds.token}`,
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify(payload),
|
|
31
|
+
});
|
|
32
|
+
if (res.status === 401)
|
|
33
|
+
throw new Error('API key invalid or revoked. Run `lumo auth login`.');
|
|
34
|
+
if (res.status !== 201) {
|
|
35
|
+
let serverMsg = null;
|
|
36
|
+
try {
|
|
37
|
+
const errBody = (await res.json());
|
|
38
|
+
if (typeof errBody.error === 'string')
|
|
39
|
+
serverMsg = errBody.error;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// body wasn't JSON
|
|
43
|
+
}
|
|
44
|
+
throw new Error(serverMsg ?? `progress comment failed (HTTP ${res.status})`);
|
|
45
|
+
}
|
|
46
|
+
return (await res.json());
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runWrapPanel = runWrapPanel;
|
|
4
|
+
/** Run each section in order; print its title, prepare, then run if it has content. */
|
|
5
|
+
async function runWrapPanel(sections, opts) {
|
|
6
|
+
for (const section of sections) {
|
|
7
|
+
process.stdout.write(`\n━━ ${section.title} ━━\n`);
|
|
8
|
+
const hasContent = await section.prepare();
|
|
9
|
+
if (!hasContent) {
|
|
10
|
+
process.stdout.write('(无内容)\n');
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
await section.run(opts);
|
|
14
|
+
}
|
|
15
|
+
}
|