clawt 2.10.0 → 2.11.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.
Files changed (57) hide show
  1. package/.claude/agent-memory/docs-sync-updater/MEMORY.md +14 -7
  2. package/.claude/agents/docs-sync-updater.md +11 -0
  3. package/README.md +80 -284
  4. package/dist/index.js +839 -307
  5. package/dist/postinstall.js +272 -0
  6. package/docs/spec.md +84 -22
  7. package/package.json +1 -1
  8. package/src/commands/remove.ts +21 -28
  9. package/src/commands/run.ts +68 -206
  10. package/src/constants/config.ts +4 -0
  11. package/src/constants/index.ts +11 -1
  12. package/src/constants/messages/common.ts +41 -0
  13. package/src/constants/messages/config.ts +5 -0
  14. package/src/constants/messages/create.ts +5 -0
  15. package/src/constants/messages/index.ts +29 -0
  16. package/src/constants/messages/merge.ts +42 -0
  17. package/src/constants/messages/remove.ts +15 -0
  18. package/src/constants/messages/reset.ts +7 -0
  19. package/src/constants/messages/resume.ts +12 -0
  20. package/src/constants/messages/run.ts +46 -0
  21. package/src/constants/messages/status.ts +25 -0
  22. package/src/constants/messages/sync.ts +24 -0
  23. package/src/constants/messages/validate.ts +25 -0
  24. package/src/constants/progress.ts +39 -0
  25. package/src/types/command.ts +4 -0
  26. package/src/types/config.ts +2 -0
  27. package/src/types/index.ts +1 -0
  28. package/src/types/taskFile.ts +13 -0
  29. package/src/utils/formatter.ts +16 -0
  30. package/src/utils/index.ts +8 -4
  31. package/src/utils/progress-render.ts +90 -0
  32. package/src/utils/progress.ts +213 -0
  33. package/src/utils/task-executor.ts +365 -0
  34. package/src/utils/task-file.ts +87 -0
  35. package/src/utils/worktree-matcher.ts +92 -0
  36. package/src/utils/worktree.ts +27 -0
  37. package/tests/unit/commands/config.test.ts +110 -0
  38. package/tests/unit/commands/create.test.ts +115 -0
  39. package/tests/unit/commands/list.test.ts +118 -0
  40. package/tests/unit/commands/merge.test.ts +323 -0
  41. package/tests/unit/commands/remove.test.ts +240 -0
  42. package/tests/unit/commands/reset.test.ts +124 -0
  43. package/tests/unit/commands/resume.test.ts +91 -0
  44. package/tests/unit/commands/run.test.ts +456 -0
  45. package/tests/unit/commands/status.test.ts +214 -0
  46. package/tests/unit/commands/sync.test.ts +208 -0
  47. package/tests/unit/commands/validate.test.ts +382 -0
  48. package/tests/unit/constants/config.test.ts +1 -0
  49. package/tests/unit/constants/messages.test.ts +1 -1
  50. package/tests/unit/utils/config.test.ts +21 -1
  51. package/tests/unit/utils/formatter.test.ts +70 -1
  52. package/tests/unit/utils/git.test.ts +44 -0
  53. package/tests/unit/utils/progress.test.ts +255 -0
  54. package/tests/unit/utils/task-file.test.ts +236 -0
  55. package/tests/unit/utils/validate-snapshot.test.ts +25 -0
  56. package/tests/unit/utils/worktree-matcher.test.ts +81 -5
  57. package/tests/unit/utils/worktree.test.ts +26 -1
@@ -0,0 +1,39 @@
1
+ /** Braille spinner 帧序列 */
2
+ export const SPINNER_FRAMES: readonly string[] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
3
+
4
+ /** spinner 刷新间隔(毫秒) */
5
+ export const SPINNER_INTERVAL_MS = 100;
6
+
7
+ /** ANSI 转义:光标上移 n 行 */
8
+ export const CURSOR_UP = (n: number): string => `\x1B[${n}A`;
9
+
10
+ /** ANSI 转义:清除从光标到行尾 */
11
+ export const CLEAR_LINE = '\x1B[0K';
12
+
13
+ /** ANSI 转义:隐藏光标 */
14
+ export const CURSOR_HIDE = '\x1B[?25l';
15
+
16
+ /** ANSI 转义:显示光标 */
17
+ export const CURSOR_SHOW = '\x1B[?25h';
18
+
19
+ /** 任务状态图标 */
20
+ export const TASK_STATUS_ICONS = {
21
+ /** 排队中 */
22
+ PENDING: '◦',
23
+ /** 完成 */
24
+ DONE: '✓',
25
+ /** 失败 */
26
+ FAILED: '✗',
27
+ } as const;
28
+
29
+ /** 任务状态标签 */
30
+ export const TASK_STATUS_LABELS = {
31
+ /** 排队中 */
32
+ PENDING: '排队中',
33
+ /** 运行中 */
34
+ RUNNING: '运行中',
35
+ /** 完成 */
36
+ DONE: '完成',
37
+ /** 失败 */
38
+ FAILED: '失败',
39
+ } as const;
@@ -12,6 +12,10 @@ export interface RunOptions {
12
12
  branch: string;
13
13
  /** 任务列表(支持多次 --tasks 传入),不传则在 worktree 中打开 Claude Code 交互式界面 */
14
14
  tasks?: string[];
15
+ /** 最大并发数(Commander 传入为字符串),0 表示不限制 */
16
+ concurrency?: string;
17
+ /** 任务文件路径(与 --tasks 互斥) */
18
+ file?: string;
15
19
  }
16
20
 
17
21
  /** validate 命令选项 */
@@ -8,6 +8,8 @@ export interface ClawtConfig {
8
8
  autoPullPush: boolean;
9
9
  /** 执行破坏性操作(reset、validate --clean)前是否提示确认 */
10
10
  confirmDestructiveOps: boolean;
11
+ /** run 命令默认最大并发数,0 表示不限制 */
12
+ maxConcurrency: number;
11
13
  }
12
14
 
13
15
  /** 单个配置项的完整定义(默认值 + 描述) */
@@ -4,3 +4,4 @@ export type { WorktreeInfo, WorktreeStatus } from './worktree.js';
4
4
  export type { ClaudeCodeResult } from './claudeCode.js';
5
5
  export type { TaskResult, TaskSummary } from './taskResult.js';
6
6
  export type { WorktreeDetailedStatus, MainWorktreeStatus, SnapshotInfo, StatusResult } from './status.js';
7
+ export type { TaskFileEntry, ParseTaskFileOptions } from './taskFile.js';
@@ -0,0 +1,13 @@
1
+ /** 任务文件中单个任务条目 */
2
+ export interface TaskFileEntry {
3
+ /** 分支名(使用 -b 模式时可选) */
4
+ branch?: string;
5
+ /** 任务描述 */
6
+ task: string;
7
+ }
8
+
9
+ /** parseTaskFile 解析选项 */
10
+ export interface ParseTaskFileOptions {
11
+ /** 是否要求每个任务块必须包含分支名,默认 true */
12
+ branchRequired?: boolean;
13
+ }
@@ -118,3 +118,19 @@ export function formatWorktreeStatus(status: WorktreeStatus): string {
118
118
 
119
119
  return parts.join(' ');
120
120
  }
121
+
122
+ /**
123
+ * 将毫秒时长格式化为人类可读字符串
124
+ * 小于 60s 显示秒数,大于等于 60s 显示 分m秒s
125
+ * @param {number} ms - 毫秒数
126
+ * @returns {string} 格式化后的时长字符串
127
+ */
128
+ export function formatDuration(ms: number): string {
129
+ const totalSeconds = ms / 1000;
130
+ if (totalSeconds < 60) {
131
+ return `${totalSeconds.toFixed(1)}s`;
132
+ }
133
+ const minutes = Math.floor(totalSeconds / 60);
134
+ const seconds = Math.floor(totalSeconds % 60);
135
+ return `${minutes}m${String(seconds).padStart(2, '0')}s`;
136
+ }
@@ -47,12 +47,16 @@ export {
47
47
  } from './git.js';
48
48
  export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
49
49
  export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
50
- export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus } from './worktree.js';
50
+ export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus, createWorktreesByBranches } from './worktree.js';
51
51
  export { loadConfig, writeDefaultConfig, getConfigValue, ensureClawtDirs } from './config.js';
52
- export { printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle } from './formatter.js';
52
+ export { printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration } from './formatter.js';
53
53
  export { ensureDir, removeEmptyDir } from './fs.js';
54
54
  export { multilineInput } from './prompt.js';
55
55
  export { launchInteractiveClaude } from './claude.js';
56
56
  export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
57
- export { findExactMatch, findFuzzyMatches, promptSelectBranch, resolveTargetWorktree } from './worktree-matcher.js';
58
- export type { WorktreeResolveMessages } from './worktree-matcher.js';
57
+ export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees } from './worktree-matcher.js';
58
+ export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
59
+ export { ProgressRenderer } from './progress.js';
60
+ export { parseTaskFile, loadTaskFile } from './task-file.js';
61
+ export { executeBatchTasks } from './task-executor.js';
62
+
@@ -0,0 +1,90 @@
1
+ import chalk from 'chalk';
2
+ import { TASK_STATUS_ICONS, TASK_STATUS_LABELS } from '../constants/index.js';
3
+ import { formatDuration } from './formatter.js';
4
+
5
+ /** 单个任务的进度状态 */
6
+ export interface TaskProgress {
7
+ /** 任务序号(从 1 开始) */
8
+ index: number;
9
+ /** 分支名(用于显示) */
10
+ branch: string;
11
+ /** worktree 路径(完成/失败后显示,终端可点击跳转) */
12
+ path: string;
13
+ /** 任务状态 */
14
+ status: 'pending' | 'running' | 'done' | 'failed';
15
+ /** 任务启动时间戳 */
16
+ startedAt: number;
17
+ /** 任务完成时间戳 */
18
+ finishedAt: number | null;
19
+ /** 最后活动时间戳(stderr 有输出时更新) */
20
+ lastActiveAt: number;
21
+ /** 耗时(毫秒),完成后由 ClaudeCodeResult 填入 */
22
+ durationMs: number | null;
23
+ /** 费用(美元),完成后由 ClaudeCodeResult 填入 */
24
+ costUsd: number | null;
25
+ }
26
+
27
+ /**
28
+ * 计算分支名的最大显示宽度,用于对齐
29
+ * @param {TaskProgress[]} tasks - 任务列表
30
+ * @returns {number} 最大分支名长度
31
+ */
32
+ export function getMaxBranchWidth(tasks: TaskProgress[]): number {
33
+ return Math.max(...tasks.map((t) => t.branch.length));
34
+ }
35
+
36
+ /**
37
+ * 渲染单个任务行(TTY 模式)
38
+ * 格式: [1/3] feat-1 ⠹ 运行中 1m23s
39
+ * 完成/失败后追加 worktree 路径,终端可点击跳转
40
+ * @param {TaskProgress} task - 任务进度
41
+ * @param {number} total - 总任务数
42
+ * @param {number} maxBranchWidth - 分支名最大宽度(用于对齐)
43
+ * @param {string} spinnerChar - 当前 spinner 帧字符
44
+ * @returns {string} 渲染后的单行字符串(含 chalk 颜色)
45
+ */
46
+ export function renderTaskLine(task: TaskProgress, total: number, maxBranchWidth: number, spinnerChar: string): string {
47
+ const indexStr = `[${task.index}/${total}]`;
48
+ const branchStr = task.branch.padEnd(maxBranchWidth);
49
+
50
+ switch (task.status) {
51
+ case 'pending': {
52
+ return `${indexStr} ${branchStr} ${chalk.gray(TASK_STATUS_ICONS.PENDING)} ${chalk.gray(TASK_STATUS_LABELS.PENDING)} ${chalk.dim(task.path)}`;
53
+ }
54
+ case 'running': {
55
+ const elapsed = formatDuration(Date.now() - task.startedAt);
56
+ return `${indexStr} ${branchStr} ${chalk.cyan(spinnerChar)} ${chalk.cyan(TASK_STATUS_LABELS.RUNNING)} ${chalk.gray(elapsed)} ${chalk.dim(task.path)}`;
57
+ }
58
+ case 'done': {
59
+ const duration = task.durationMs != null ? formatDuration(task.durationMs) : 'N/A';
60
+ const cost = task.costUsd != null ? `$${task.costUsd.toFixed(2)}` : '';
61
+ return `${indexStr} ${branchStr} ${chalk.green(TASK_STATUS_ICONS.DONE)} ${chalk.green(TASK_STATUS_LABELS.DONE)} ${chalk.gray(duration)} ${chalk.yellow(cost)} ${chalk.dim(task.path)}`;
62
+ }
63
+ case 'failed': {
64
+ const duration = task.durationMs != null ? formatDuration(task.durationMs) : 'N/A';
65
+ return `${indexStr} ${branchStr} ${chalk.red(TASK_STATUS_ICONS.FAILED)} ${chalk.red(TASK_STATUS_LABELS.FAILED)} ${chalk.gray(duration)} ${chalk.dim(task.path)}`;
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 渲染汇总行,统计各状态的任务数
72
+ * 格式: [2/8 运行中, 3/8 已完成, 3/8 排队中]
73
+ * @param {TaskProgress[]} tasks - 任务列表
74
+ * @param {number} total - 总任务数
75
+ * @returns {string} 汇总行字符串
76
+ */
77
+ export function renderSummaryLine(tasks: TaskProgress[], total: number): string {
78
+ const running = tasks.filter((t) => t.status === 'running').length;
79
+ const done = tasks.filter((t) => t.status === 'done').length;
80
+ const failed = tasks.filter((t) => t.status === 'failed').length;
81
+ const pending = tasks.filter((t) => t.status === 'pending').length;
82
+
83
+ const parts: string[] = [];
84
+ if (running > 0) parts.push(chalk.cyan(`${running}/${total} ${TASK_STATUS_LABELS.RUNNING}`));
85
+ if (done > 0) parts.push(chalk.green(`${done}/${total} ${TASK_STATUS_LABELS.DONE}`));
86
+ if (failed > 0) parts.push(chalk.red(`${failed}/${total} ${TASK_STATUS_LABELS.FAILED}`));
87
+ if (pending > 0) parts.push(chalk.gray(`${pending}/${total} ${TASK_STATUS_LABELS.PENDING}`));
88
+
89
+ return `[${parts.join(', ')}]`;
90
+ }
@@ -0,0 +1,213 @@
1
+ import {
2
+ SPINNER_FRAMES,
3
+ SPINNER_INTERVAL_MS,
4
+ CURSOR_UP,
5
+ CLEAR_LINE,
6
+ CURSOR_HIDE,
7
+ CURSOR_SHOW,
8
+ MESSAGES,
9
+ } from '../constants/index.js';
10
+ import { formatDuration } from './formatter.js';
11
+ import type { TaskProgress } from './progress-render.js';
12
+ import { getMaxBranchWidth, renderTaskLine, renderSummaryLine } from './progress-render.js';
13
+
14
+ /**
15
+ * 任务进度面板渲染器
16
+ *
17
+ * 职责:协调任务状态管理与终端渲染
18
+ * - TTY 模式:使用 ANSI 转义码实现原地多行刷新
19
+ * - 非 TTY 模式:降级为逐行输出,不使用 ANSI 转义
20
+ */
21
+ export class ProgressRenderer {
22
+ /** 所有任务的进度状态 */
23
+ private tasks: TaskProgress[];
24
+ /** 总任务数 */
25
+ private total: number;
26
+ /** 当前 spinner 帧索引 */
27
+ private frameIndex: number;
28
+ /** 定时器引用 */
29
+ private timer: ReturnType<typeof setInterval> | null;
30
+ /** 是否为 TTY 环境 */
31
+ private isTTY: boolean;
32
+ /** 已渲染的行数(用于回退光标) */
33
+ private renderedLineCount: number;
34
+ /** 是否已停止 */
35
+ private stopped: boolean;
36
+ /** 是否存在排队任务(启用汇总行渲染) */
37
+ private hasPendingTasks: boolean;
38
+
39
+ /**
40
+ * 创建进度面板渲染器
41
+ * @param {string[]} branches - 分支名列表,顺序对应任务列表
42
+ * @param {string[]} paths - worktree 路径列表,完成/失败后显示
43
+ * @param {boolean} [allRunning=true] - 是否将所有任务初始化为 running 状态,false 时初始化为 pending
44
+ */
45
+ constructor(branches: string[], paths: string[], allRunning: boolean = true) {
46
+ const now = Date.now();
47
+ this.total = branches.length;
48
+ this.frameIndex = 0;
49
+ this.timer = null;
50
+ this.isTTY = !!process.stdout.isTTY;
51
+ this.renderedLineCount = 0;
52
+ this.stopped = false;
53
+ this.hasPendingTasks = !allRunning;
54
+
55
+ this.tasks = branches.map((branch, i) => ({
56
+ index: i + 1,
57
+ branch,
58
+ path: paths[i],
59
+ status: allRunning ? 'running' : 'pending',
60
+ startedAt: allRunning ? now : 0,
61
+ finishedAt: null,
62
+ lastActiveAt: allRunning ? now : 0,
63
+ durationMs: null,
64
+ costUsd: null,
65
+ }));
66
+ }
67
+
68
+ /**
69
+ * 启动定时渲染循环
70
+ * TTY 模式下每 SPINNER_INTERVAL_MS 毫秒刷新一次面板
71
+ * 非 TTY 模式下输出状态为 running 的任务的启动信息
72
+ */
73
+ start(): void {
74
+ if (this.stopped) return;
75
+
76
+ if (!this.isTTY) {
77
+ // 非 TTY 降级:仅输出已为 running 的任务的启动信息
78
+ for (const task of this.tasks) {
79
+ if (task.status === 'running') {
80
+ console.log(MESSAGES.PROGRESS_TASK_STARTED(task.index, this.total, task.branch, task.path));
81
+ }
82
+ }
83
+ return;
84
+ }
85
+
86
+ // TTY 模式:隐藏光标,启动定时刷新
87
+ process.stdout.write(CURSOR_HIDE);
88
+ this.render();
89
+ this.timer = setInterval(() => {
90
+ this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
91
+ this.render();
92
+ }, SPINNER_INTERVAL_MS);
93
+
94
+ // 确保定时器不阻止进程退出
95
+ if (this.timer.unref) {
96
+ this.timer.unref();
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 更新指定任务的最后活动时间戳
102
+ * 当 child.stderr 有输出时调用,表示任务仍然活跃
103
+ * @param {number} index - 任务索引(从 0 开始)
104
+ */
105
+ updateActivity(index: number): void {
106
+ this.tasks[index].lastActiveAt = Date.now();
107
+ }
108
+
109
+ /**
110
+ * 标记指定任务为运行中状态
111
+ * 将 pending 任务标记为 running 并设置启动时间戳
112
+ * @param {number} index - 任务索引(从 0 开始)
113
+ */
114
+ markRunning(index: number): void {
115
+ const task = this.tasks[index];
116
+ const now = Date.now();
117
+ task.status = 'running';
118
+ task.startedAt = now;
119
+ task.lastActiveAt = now;
120
+
121
+ if (!this.isTTY) {
122
+ // 非 TTY 降级:输出启动信息
123
+ console.log(MESSAGES.PROGRESS_TASK_STARTED(task.index, this.total, task.branch, task.path));
124
+ }
125
+ }
126
+
127
+ /**
128
+ * 标记指定任务为完成状态
129
+ * @param {number} index - 任务索引(从 0 开始)
130
+ * @param {number} durationMs - 耗时(毫秒)
131
+ * @param {number} costUsd - 费用(美元)
132
+ */
133
+ markDone(index: number, durationMs: number, costUsd: number): void {
134
+ const task = this.tasks[index];
135
+ task.status = 'done';
136
+ task.finishedAt = Date.now();
137
+ task.durationMs = durationMs;
138
+ task.costUsd = costUsd;
139
+
140
+ if (!this.isTTY) {
141
+ // 非 TTY 降级:直接输出完成信息
142
+ const duration = formatDuration(durationMs);
143
+ const cost = `$${costUsd.toFixed(2)}`;
144
+ console.log(MESSAGES.PROGRESS_TASK_DONE(task.index, this.total, task.branch, duration, cost, task.path));
145
+ }
146
+ }
147
+
148
+ /**
149
+ * 标记指定任务为失败状态
150
+ * @param {number} index - 任务索引(从 0 开始)
151
+ * @param {number} durationMs - 耗时(毫秒)
152
+ */
153
+ markFailed(index: number, durationMs: number): void {
154
+ const task = this.tasks[index];
155
+ task.status = 'failed';
156
+ task.finishedAt = Date.now();
157
+ task.durationMs = durationMs;
158
+
159
+ if (!this.isTTY) {
160
+ // 非 TTY 降级:直接输出失败信息
161
+ const duration = formatDuration(durationMs);
162
+ console.log(MESSAGES.PROGRESS_TASK_FAILED(task.index, this.total, task.branch, duration, task.path));
163
+ }
164
+ }
165
+
166
+ /**
167
+ * 停止渲染循环并恢复光标
168
+ * 在所有任务完成或 SIGINT 中断时调用
169
+ */
170
+ stop(): void {
171
+ if (this.stopped) return;
172
+ this.stopped = true;
173
+
174
+ if (this.timer) {
175
+ clearInterval(this.timer);
176
+ this.timer = null;
177
+ }
178
+
179
+ if (this.isTTY) {
180
+ // 最后渲染一次,确保最终状态显示正确
181
+ this.render();
182
+ // 恢复光标显示
183
+ process.stdout.write(CURSOR_SHOW);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * 执行一次完整的面板渲染
189
+ * 先回退光标到面板起始位置,再逐行输出
190
+ */
191
+ private render(): void {
192
+ const maxBranchWidth = getMaxBranchWidth(this.tasks);
193
+ const spinnerChar = SPINNER_FRAMES[this.frameIndex];
194
+ const lines = this.tasks.map((task) => renderTaskLine(task, this.total, maxBranchWidth, spinnerChar));
195
+
196
+ // 存在排队任务时追加汇总行
197
+ if (this.hasPendingTasks) {
198
+ lines.push(renderSummaryLine(this.tasks, this.total));
199
+ }
200
+
201
+ // 回退光标到面板起始位置
202
+ if (this.renderedLineCount > 0) {
203
+ process.stdout.write(CURSOR_UP(this.renderedLineCount));
204
+ }
205
+
206
+ // 逐行输出,每行末尾清除残留字符
207
+ for (const line of lines) {
208
+ process.stdout.write(`${line}${CLEAR_LINE}\n`);
209
+ }
210
+
211
+ this.renderedLineCount = lines.length;
212
+ }
213
+ }