claude-coder 1.5.0 → 1.5.2

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/README.md CHANGED
@@ -99,7 +99,7 @@ your-project/
99
99
  test.env # 测试凭证(API Key 等,可选)
100
100
  playwright-auth.json # Playwright 登录状态(可选,auth 命令生成)
101
101
  .runtime/ # 临时文件
102
- logs/ # 每 session 独立日志 + activity log
102
+ logs/ # 每 session 独立日志(含工具调用记录)
103
103
  requirements.md # 需求文档(可选)
104
104
  ```
105
105
 
@@ -85,7 +85,7 @@ flowchart TB
85
85
 
86
86
  subgraph SDK["Claude Agent SDK"]
87
87
  query["query() 函数"]
88
- hook_sys["PreToolUse hook<br/>内联回调"]
88
+ hook_sys["PreToolUse hook<br/>hooks.js 工厂"]
89
89
  end
90
90
 
91
91
  subgraph Files["文件系统 (.claude-coder/)"]
@@ -154,11 +154,12 @@ flowchart LR
154
154
  ## 3. 模块职责
155
155
 
156
156
  ```
157
- bin/cli.js CLI 入口:参数解析、命令路由、SDK peerDep 检查
157
+ bin/cli.js CLI 入口:参数解析、命令路由
158
158
  src/
159
159
  config.js 配置管理:.env 加载、模型映射、环境变量构建、全局同步
160
160
  runner.js 主循环:scan → session → validate → retry/rollback
161
- session.js SDK 交互:query() 调用、hook 绑定、停顿检测、日志流
161
+ session.js SDK 交互:query() 调用、SDK 加载、日志流
162
+ hooks.js Hook 工厂:停顿检测 + 编辑死循环防护(可复用于所有 session 类型)
162
163
  prompts.js 提示语构建:系统 prompt 组合 + 条件 hint + 任务分解指导
163
164
  init.js 环境初始化:读取 profile 执行依赖安装、服务启动、健康检查
164
165
  scanner.js 初始化扫描:调用 runScanSession + 重试
@@ -184,7 +185,8 @@ templates/
184
185
  | `bin/cli.js` | CLI 入口 |
185
186
  | `src/config.js` | .env 加载、模型映射 |
186
187
  | `src/runner.js` | Harness 主循环 |
187
- | `src/session.js` | SDK query() 封装 + hook |
188
+ | `src/session.js` | SDK query() 封装 + 日志流 |
189
+ | `src/hooks.js` | Hook 工厂(停顿检测 + 编辑防护,可复用于所有 session 类型) |
188
190
  | `src/prompts.js` | 提示语构建(系统 prompt + 条件 hint + 任务分解指导) |
189
191
  | `src/init.js` | 环境初始化(依赖安装、服务启动) |
190
192
  | `src/scanner.js` | 项目初始化扫描 |
@@ -207,7 +209,7 @@ templates/
207
209
  | `session_result.json` | 每次 session 结束 | 当前 session 结果(扁平格式,向后兼容旧 `current` 包装) |
208
210
  | `playwright-auth.json` | `claude-coder auth` | Playwright 登录状态(cookies + localStorage) |
209
211
  | `tests.json` | 首次测试时 | 验证记录(防止反复测试) |
210
- | `.runtime/` | 运行时 | 临时文件(phase、step、logs/session_N.activity.log |
212
+ | `.runtime/` | 运行时 | 临时文件(phase、step、logs/);工具调用记录合并到 session log |
211
213
 
212
214
  ---
213
215
 
@@ -250,7 +252,7 @@ flowchart TB
250
252
  |---|---|---|---|
251
253
  | **编码** | CLAUDE.md | `buildCodingPrompt()` + 11 个条件 hint | 主循环每次迭代 |
252
254
  | **扫描** | CLAUDE.md + SCAN_PROTOCOL.md | `buildScanPrompt()` + 任务分解指导 + profile 质量要求 | 首次运行 |
253
- | **追加** | CLAUDE.md | `buildAddPrompt()` + 任务分解指导 | `claude-coder add` |
255
+ | **追加** | 精简角色提示(不注入 CLAUDE.md | `buildAddPrompt()` + 任务分解指导 + session_result 格式 | `claude-coder add` |
254
256
 
255
257
  ### 编码 Session 的 11 个条件 Hint
256
258
 
@@ -372,19 +374,19 @@ flowchart TD
372
374
  end
373
375
 
374
376
  subgraph after ["优化后:Harness 注入上下文"]
375
- B1["Harness 预读文件"] --> B2["注入 Hint 7: 任务上下文"]
376
- B1 --> B3["注入 Hint 8: 会话记忆"]
377
+ B1["Harness 预读文件"] --> B2["注入 Hint 6: 任务上下文"]
378
+ B1 --> B3["注入 Hint 7: 会话记忆"]
377
379
  B2 --> B4["Agent prompt 就绪"]
378
380
  B3 --> B4
379
381
  B4 --> B5["Agent 直接开始编码"]
380
382
  end
381
383
  ```
382
384
 
383
- ### Hint 7: 任务上下文注入
385
+ ### Hint 6: 任务上下文注入
384
386
 
385
387
  Harness 在 `buildCodingPrompt()` 中预读 `tasks.json`,将下一个待办任务的 id、description、category、steps 数量和整体进度注入 user prompt。Agent 无需自行读取 `tasks.json`。
386
388
 
387
- ### Hint 8: 会话记忆注入
389
+ ### Hint 7: 会话记忆注入
388
390
 
389
391
  Harness 在 `buildCodingPrompt()` 中预读 `session_result.json`,将上次会话的 task_id、结果和 notes 摘要注入 user prompt。Agent 无需自行读取历史 session 数据。
390
392
 
@@ -450,7 +452,7 @@ query({
450
452
  ### V2 迁移条件(等待稳定后)
451
453
 
452
454
  1. V2 去掉 `unstable_` 前缀,正式发布
453
- 2. V2 支持 Hooks(当前项目依赖 PreToolUse 做 spinner 和 activity log)
455
+ 2. V2 支持 Hooks(当前项目依赖 PreToolUse 做 spinner 和日志记录)
454
456
  3. V2 支持 Subagents(未来可能用于扫描 Agent / 编码 Agent 分离)
455
457
 
456
458
  ### 可利用但尚未使用的 V1 特性
@@ -470,13 +472,6 @@ query({
470
472
 
471
473
  ### P0 — 近期
472
474
 
473
- | 方向 | 说明 |
474
- |------|------|
475
- | **文件保护 Deny-list** | PreToolUse hook 拦截对保护文件的写入(比文字规则更硬性) |
476
- | **成本预算控制** | `.env` 新增 `MAX_COST_USD`,超预算自动停止 |
477
-
478
- ### P1 — 中期
479
-
480
475
  | 方向 | 说明 |
481
476
  |------|------|
482
477
  | **TCR 纪律** | Test && Commit \|\| Revert,可配置 strict/smart/off |
@@ -484,7 +479,7 @@ query({
484
479
  | **Reminders 注入** | 用户自定义提醒文件,拼接到编码 prompt |
485
480
  | **MCP 工具自动检测** | `claude mcp list` 自动启用已安装工具 |
486
481
 
487
- ### P2 — 远期
482
+ ### P1 — 远期
488
483
 
489
484
  | 方向 | 说明 |
490
485
  |------|------|
package/docs/README.en.md CHANGED
@@ -87,7 +87,7 @@ your-project/
87
87
  test.env # Test credentials (API keys, optional)
88
88
  playwright-auth.json # Playwright login state (optional, via auth command)
89
89
  .runtime/ # Temp files
90
- logs/ # Per-session logs + activity logs
90
+ logs/ # Per-session logs (with tool call traces)
91
91
  requirements.md # Requirements (optional)
92
92
  ```
93
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-coder",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Claude Coder — Autonomous coding agent harness powered by Claude Code SDK. Scan, plan, code, validate, git-commit in a loop.",
5
5
  "bin": {
6
6
  "claude-coder": "bin/cli.js"
package/src/hooks.js ADDED
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const { inferPhaseStep } = require('./indicator');
4
+ const { log } = require('./config');
5
+
6
+ const EDIT_THRESHOLD = 5;
7
+
8
+ function logToolCall(logStream, input) {
9
+ if (!logStream) return;
10
+ const target = input.tool_input?.file_path || input.tool_input?.path || '';
11
+ const cmd = input.tool_input?.command || '';
12
+ const pattern = input.tool_input?.pattern || '';
13
+ const detail = target || cmd.slice(0, 200) || (pattern ? `pattern: ${pattern}` : '');
14
+ if (detail) {
15
+ logStream.write(`[${new Date().toISOString()}] ${input.tool_name}: ${detail}\n`);
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Create unified session hooks with configurable stall detection and edit guard.
21
+ * @param {Indicator} indicator
22
+ * @param {WriteStream|null} logStream
23
+ * @param {object} [options]
24
+ * @param {boolean} [options.enableStallDetection=false]
25
+ * @param {number} [options.stallTimeoutMs=1800000]
26
+ * @param {boolean} [options.enableEditGuard=false]
27
+ * @returns {{ hooks: object, cleanup: () => void, isStalled: () => boolean }}
28
+ */
29
+ function createSessionHooks(indicator, logStream, options = {}) {
30
+ const {
31
+ enableStallDetection = false,
32
+ stallTimeoutMs = 1800000,
33
+ enableEditGuard = false,
34
+ } = options;
35
+
36
+ const editCounts = {};
37
+ let stallDetected = false;
38
+ let stallChecker = null;
39
+
40
+ if (enableStallDetection) {
41
+ stallChecker = setInterval(() => {
42
+ const idleMs = Date.now() - indicator.lastToolTime;
43
+ if (idleMs > stallTimeoutMs && !stallDetected) {
44
+ stallDetected = true;
45
+ const idleMin = Math.floor(idleMs / 60000);
46
+ log('warn', `无新工具调用超过 ${idleMin} 分钟,自动中断 session`);
47
+ if (logStream) {
48
+ logStream.write(`[${new Date().toISOString()}] STALL: 无工具调用 ${idleMin} 分钟,自动中断\n`);
49
+ }
50
+ }
51
+ }, 30000);
52
+ }
53
+
54
+ const hooks = {
55
+ PreToolUse: [{
56
+ matcher: '*',
57
+ hooks: [async (input) => {
58
+ inferPhaseStep(indicator, input.tool_name, input.tool_input);
59
+ logToolCall(logStream, input);
60
+
61
+ if (enableEditGuard) {
62
+ const target = input.tool_input?.file_path || input.tool_input?.path || '';
63
+ if (['Write', 'Edit', 'MultiEdit'].includes(input.tool_name) && target) {
64
+ editCounts[target] = (editCounts[target] || 0) + 1;
65
+ if (editCounts[target] > EDIT_THRESHOLD) {
66
+ return {
67
+ decision: 'block',
68
+ message: `已对 ${target} 编辑 ${editCounts[target]} 次,疑似死循环。请重新审视方案后再继续。`,
69
+ };
70
+ }
71
+ }
72
+ }
73
+
74
+ return {};
75
+ }]
76
+ }]
77
+ };
78
+
79
+ return {
80
+ hooks,
81
+ cleanup() { if (stallChecker) clearInterval(stallChecker); },
82
+ isStalled() { return stallDetected; },
83
+ };
84
+ }
85
+
86
+ module.exports = { createSessionHooks };
package/src/indicator.js CHANGED
@@ -18,9 +18,8 @@ class Indicator {
18
18
  this.startTime = Date.now();
19
19
  }
20
20
 
21
- start(sessionNum, activityLogPath) {
21
+ start(sessionNum) {
22
22
  this.sessionNum = sessionNum;
23
- this.activityLogPath = activityLogPath || null;
24
23
  this.startTime = Date.now();
25
24
  this.timer = setInterval(() => this._render(), 500);
26
25
  }
@@ -44,14 +43,7 @@ class Indicator {
44
43
  }
45
44
 
46
45
  appendActivity(toolName, summary) {
47
- const ts = new Date().toISOString();
48
- const entry = `[${ts}] ${toolName}: ${summary}`;
49
- this.lastActivity = entry;
50
- try {
51
- if (this.activityLogPath) {
52
- fs.appendFileSync(this.activityLogPath, entry + '\n', 'utf8');
53
- }
54
- } catch { /* ignore */ }
46
+ this.lastActivity = `${toolName}: ${summary}`;
55
47
  }
56
48
 
57
49
  _writePhaseFile() {
package/src/prompts.js CHANGED
@@ -209,27 +209,94 @@ function buildScanPrompt(projectType, requirement) {
209
209
  ].join('\n');
210
210
  }
211
211
 
212
+ /**
213
+ * Build lightweight system prompt for add sessions.
214
+ * Add sessions only decompose requirements — no coding workflow needed.
215
+ * CLAUDE.md is NOT injected to avoid role conflict and save ~2000 tokens.
216
+ */
217
+ function buildAddSystemPrompt() {
218
+ return '你是一个任务分解专家,擅长将模糊需求拆解为结构化、可执行的原子任务。你只分析需求和分解任务,不实现任何代码。';
219
+ }
220
+
212
221
  /**
213
222
  * Build user prompt for add sessions.
223
+ * Structure: Role (primacy) → Context → CoT → TaskGuide → Instruction (recency)
214
224
  */
215
225
  function buildAddPrompt(instruction) {
226
+ const p = paths();
227
+ const projectRoot = getProjectRoot();
216
228
  const taskGuide = buildTaskGuide();
229
+
230
+ // --- Context injection: pre-read project state ---
231
+ let profileContext = '';
232
+ if (fs.existsSync(p.profile)) {
233
+ try {
234
+ const profile = JSON.parse(fs.readFileSync(p.profile, 'utf8'));
235
+ const stack = profile.tech_stack || {};
236
+ const parts = [];
237
+ if (stack.backend?.framework) parts.push(`后端: ${stack.backend.framework}`);
238
+ if (stack.frontend?.framework) parts.push(`前端: ${stack.frontend.framework}`);
239
+ if (stack.backend?.language) parts.push(`语言: ${stack.backend.language}`);
240
+ if (parts.length) profileContext = `项目技术栈: ${parts.join(', ')}`;
241
+ } catch { /* ignore */ }
242
+ }
243
+
244
+ let taskContext = '';
245
+ let recentExamples = '';
246
+ try {
247
+ const taskData = loadTasks();
248
+ if (taskData) {
249
+ const stats = getStats(taskData);
250
+ const features = taskData.features || [];
251
+ const maxId = features.length ? features[features.length - 1].id : 'feat-000';
252
+ const maxPriority = features.length ? Math.max(...features.map(f => f.priority || 0)) : 0;
253
+ const categories = [...new Set(features.map(f => f.category))].join(', ');
254
+
255
+ taskContext = `已有 ${stats.total} 个任务(${stats.done} done, ${stats.pending} pending, ${stats.failed} failed)。` +
256
+ `最大 id: ${maxId}, 最大 priority: ${maxPriority}。已有 category: ${categories}。`;
257
+
258
+ const recent = features.slice(-3);
259
+ if (recent.length) {
260
+ recentExamples = '已有任务格式参考(保持一致性):\n' +
261
+ recent.map(f => ` ${f.id}: "${f.description}" (category=${f.category}, steps=${f.steps.length}步, depends_on=[${f.depends_on.join(',')}])`).join('\n');
262
+ }
263
+ }
264
+ } catch { /* ignore */ }
265
+
217
266
  return [
218
- '重要:这是任务追加 session,不是常规编码 session。不执行 6 步流程。',
267
+ // --- Primacy zone: role + identity ---
268
+ '你是资深需求分析师,擅长将模糊需求分解为可执行的原子任务。',
269
+ '这是任务追加 session,不是编码 session。你只分解任务,不实现代码。',
219
270
  '',
220
- '步骤:',
221
- '1. 读取 .claude-coder/tasks.json 了解已有任务和最大 id/priority',
222
- '2. 读取 .claude-coder/project_profile.json 了解项目技术栈',
223
- '3. 根据用户指令追加新任务(status: pending)',
271
+
272
+ // --- Context layer ---
273
+ profileContext,
274
+ taskContext,
275
+ recentExamples,
276
+ `项目绝对路径: ${projectRoot}`,
277
+ '',
278
+
279
+ // --- CoT: explicit thinking steps ---
280
+ '执行步骤(按顺序,不可跳过):',
281
+ '1. 读取 .claude-coder/tasks.json 和 .claude-coder/project_profile.json,全面了解项目现状',
282
+ '2. 分析用户指令:识别核心功能点,判断是单任务还是需要拆分为多任务',
283
+ '3. 检查重复:对比已有任务,避免功能重叠',
284
+ '4. 确定依赖:新任务的 depends_on 引用已有或新增任务的 id,形成 DAG',
285
+ '5. 分解任务:每个任务对应一个独立可测试的功能单元,description 简明(40字内),steps 具体可操作',
286
+ '6. 追加到 tasks.json,id 和 priority 从已有最大值递增,status: pending',
287
+ '7. git add -A && git commit -m "chore: add new tasks"',
288
+ '8. 写入 session_result.json(格式:{ "session_result": "success", "task_id": "add-tasks", "status_before": "N/A", "status_after": "N/A", "git_commit": "hash", "tests_passed": false, "notes": "追加了 N 个任务:简述" })',
224
289
  '',
290
+
291
+ // --- Quality constraints ---
225
292
  taskGuide,
226
293
  '',
227
- '新任务 id 和 priority 从已有最大值递增。不修改已有任务,不实现代码。',
228
- 'git add -A && git commit -m "chore: add new tasks"',
229
- '写入 session_result.json',
294
+ '不修改已有任务,不实现代码。',
230
295
  '',
296
+
297
+ // --- Recency zone: user instruction (highest attention) ---
231
298
  `用户指令:${instruction}`,
232
- ].join('\n');
299
+ ].filter(Boolean).join('\n');
233
300
  }
234
301
 
235
302
  module.exports = {
@@ -237,5 +304,6 @@ module.exports = {
237
304
  buildCodingPrompt,
238
305
  buildTaskGuide,
239
306
  buildScanPrompt,
307
+ buildAddSystemPrompt,
240
308
  buildAddPrompt,
241
309
  };
package/src/runner.js CHANGED
@@ -5,35 +5,15 @@ const path = require('path');
5
5
  const readline = require('readline');
6
6
  const { execSync } = require('child_process');
7
7
  const { paths, log, COLOR, loadConfig, ensureLoopDir, getProjectRoot } = require('./config');
8
- const { loadTasks, saveTasks, getFeatures, getStats, findNextTask } = require('./tasks');
8
+ const { loadTasks, getFeatures, getStats, findNextTask, forceStatus } = require('./tasks');
9
9
  const { validate } = require('./validator');
10
10
  const { scan } = require('./scanner');
11
- const { runCodingSession, runAddSession } = require('./session');
11
+ const { loadSDK, runCodingSession, runAddSession } = require('./session');
12
12
 
13
13
  const MAX_RETRY = 3;
14
14
 
15
- async function requireSdk() {
16
- const pkgName = '@anthropic-ai/claude-agent-sdk';
17
- const attempts = [
18
- () => { require.resolve(pkgName); return true; },
19
- () => {
20
- const { createRequire } = require('module');
21
- createRequire(__filename).resolve(pkgName);
22
- return true;
23
- },
24
- () => {
25
- const prefix = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
26
- const sdkPath = path.join(prefix, 'lib', 'node_modules', pkgName);
27
- if (fs.existsSync(sdkPath)) return true;
28
- throw new Error('not found');
29
- },
30
- ];
31
- for (const attempt of attempts) {
32
- try { if (attempt()) return; } catch { /* try next */ }
33
- }
34
- console.error(`错误:未找到 ${pkgName}`);
35
- console.error(`请先安装:npm install -g ${pkgName}`);
36
- process.exit(1);
15
+ function sleep(ms) {
16
+ return new Promise(resolve => setTimeout(resolve, ms));
37
17
  }
38
18
 
39
19
  function getHead() {
@@ -77,17 +57,12 @@ function killServicesByProfile() {
77
57
  } catch { /* ignore profile read errors */ }
78
58
  }
79
59
 
80
- function sleepSync(ms) {
81
- const end = Date.now() + ms;
82
- while (Date.now() < end) { /* busy wait */ }
83
- }
84
-
85
- function rollback(headBefore, reason) {
60
+ async function rollback(headBefore, reason) {
86
61
  if (!headBefore || headBefore === 'none') return;
87
62
 
88
63
  killServicesByProfile();
89
64
 
90
- if (process.platform === 'win32') sleepSync(1500);
65
+ if (process.platform === 'win32') await sleep(1500);
91
66
 
92
67
  const cwd = getProjectRoot();
93
68
  const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
@@ -104,7 +79,7 @@ function rollback(headBefore, reason) {
104
79
  } catch (err) {
105
80
  if (attempt === 1) {
106
81
  log('warn', `回滚首次失败,等待后重试: ${err.message}`);
107
- sleepSync(2000);
82
+ await sleep(2000);
108
83
  } else {
109
84
  log('error', `回滚失败: ${err.message}`);
110
85
  }
@@ -123,14 +98,10 @@ function rollback(headBefore, reason) {
123
98
  function markTaskFailed() {
124
99
  const data = loadTasks();
125
100
  if (!data) return;
126
- const features = getFeatures(data);
127
- for (const f of features) {
128
- if (f.status === 'in_progress') {
129
- f.status = 'failed';
130
- break;
131
- }
101
+ const result = forceStatus(data, 'failed');
102
+ if (result) {
103
+ log('warn', `已将任务 ${result.id} 强制标记为 failed`);
132
104
  }
133
- saveTasks(data);
134
105
  }
135
106
 
136
107
  function tryPush() {
@@ -192,20 +163,17 @@ async function run(requirement, opts = {}) {
192
163
  console.log('============================================');
193
164
  console.log('');
194
165
 
195
- // Load config
196
166
  const config = loadConfig();
197
167
  if (config.provider !== 'claude' && config.baseUrl) {
198
168
  log('ok', `模型配置已加载: ${config.provider}${config.model ? ` (${config.model})` : ''}`);
199
169
  }
200
170
 
201
- // Read requirement from requirements.md or CLI
202
171
  const reqFile = path.join(projectRoot, 'requirements.md');
203
172
  if (fs.existsSync(reqFile) && !requirement) {
204
173
  requirement = fs.readFileSync(reqFile, 'utf8');
205
174
  log('ok', '已读取需求文件: requirements.md');
206
175
  }
207
176
 
208
- // Ensure git repo
209
177
  try {
210
178
  execSync('git rev-parse --is-inside-work-tree', { cwd: projectRoot, stdio: 'ignore' });
211
179
  } catch {
@@ -217,7 +185,6 @@ async function run(requirement, opts = {}) {
217
185
  });
218
186
  }
219
187
 
220
- // Initialization (scan) if needed
221
188
  if (!fs.existsSync(p.profile) || !fs.existsSync(p.tasksFile)) {
222
189
  if (!requirement) {
223
190
  log('error', '首次运行需要提供需求描述');
@@ -238,7 +205,7 @@ async function run(requirement, opts = {}) {
238
205
  return;
239
206
  }
240
207
 
241
- await requireSdk();
208
+ await loadSDK();
242
209
  const scanResult = await scan(requirement, { projectRoot });
243
210
  if (!scanResult.success) {
244
211
  console.log('');
@@ -253,8 +220,7 @@ async function run(requirement, opts = {}) {
253
220
  printStats();
254
221
  }
255
222
 
256
- // Coding loop
257
- if (!dryRun) await requireSdk();
223
+ if (!dryRun) await loadSDK();
258
224
  log('info', `开始编码循环 (最多 ${maxSessions} 个会话) ...`);
259
225
  console.log('');
260
226
 
@@ -292,7 +258,6 @@ async function run(requirement, opts = {}) {
292
258
  const nextTask = findNextTask(taskData);
293
259
  const taskId = nextTask?.id || 'unknown';
294
260
 
295
- // Run coding session
296
261
  const sessionResult = await runCodingSession(session, {
297
262
  projectRoot,
298
263
  taskId,
@@ -304,7 +269,7 @@ async function run(requirement, opts = {}) {
304
269
  if (sessionResult.stalled) {
305
270
  log('warn', `Session ${session} 因停顿超时中断,跳过校验直接重试`);
306
271
  consecutiveFailures++;
307
- rollback(headBefore, '停顿超时');
272
+ await rollback(headBefore, '停顿超时');
308
273
  if (consecutiveFailures >= MAX_RETRY) {
309
274
  log('error', `连续失败 ${MAX_RETRY} 次,跳过当前任务`);
310
275
  markTaskFailed();
@@ -320,7 +285,6 @@ async function run(requirement, opts = {}) {
320
285
  continue;
321
286
  }
322
287
 
323
- // Validate
324
288
  log('info', '开始 harness 校验 ...');
325
289
  const validateResult = await validate(headBefore);
326
290
 
@@ -338,7 +302,7 @@ async function run(requirement, opts = {}) {
338
302
  timestamp: new Date().toISOString(),
339
303
  result: 'success',
340
304
  cost: sessionResult.cost,
341
- taskId: validateResult.sessionData?.task_id || null,
305
+ taskId: validateResult.sessionData?.task_id || taskId,
342
306
  statusAfter: validateResult.sessionData?.status_after || null,
343
307
  notes: validateResult.sessionData?.notes || null,
344
308
  });
@@ -347,7 +311,16 @@ async function run(requirement, opts = {}) {
347
311
  consecutiveFailures++;
348
312
  log('error', `Session ${session} 校验失败 (连续失败: ${consecutiveFailures}/${MAX_RETRY})`);
349
313
 
350
- rollback(headBefore, '校验失败');
314
+ appendProgress({
315
+ session,
316
+ timestamp: new Date().toISOString(),
317
+ result: 'fatal',
318
+ cost: sessionResult.cost,
319
+ taskId,
320
+ reason: validateResult.sessionData?.reason || '校验失败',
321
+ });
322
+
323
+ await rollback(headBefore, '校验失败');
351
324
 
352
325
  if (consecutiveFailures >= MAX_RETRY) {
353
326
  log('error', `连续失败 ${MAX_RETRY} 次,跳过当前任务`);
@@ -357,7 +330,6 @@ async function run(requirement, opts = {}) {
357
330
  }
358
331
  }
359
332
 
360
- // Periodic pause
361
333
  if (pauseEvery > 0 && session % pauseEvery === 0) {
362
334
  console.log('');
363
335
  printStats();
@@ -369,10 +341,8 @@ async function run(requirement, opts = {}) {
369
341
  }
370
342
  }
371
343
 
372
- // Cleanup: stop services after loop ends
373
344
  killServicesByProfile();
374
345
 
375
- // Final report
376
346
  console.log('');
377
347
  console.log('============================================');
378
348
  console.log(' 运行结束');
@@ -382,7 +352,7 @@ async function run(requirement, opts = {}) {
382
352
  }
383
353
 
384
354
  async function add(instruction, opts = {}) {
385
- await requireSdk();
355
+ await loadSDK();
386
356
  const p = paths();
387
357
  const projectRoot = getProjectRoot();
388
358
  ensureLoopDir();
package/src/session.js CHANGED
@@ -3,8 +3,11 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { paths, loadConfig, buildEnvVars, getAllowedTools, log } = require('./config');
6
- const { Indicator, inferPhaseStep } = require('./indicator');
7
- const { buildSystemPrompt, buildCodingPrompt, buildScanPrompt, buildAddPrompt } = require('./prompts');
6
+ const { Indicator } = require('./indicator');
7
+ const { createSessionHooks } = require('./hooks');
8
+ const { buildSystemPrompt, buildCodingPrompt, buildScanPrompt, buildAddSystemPrompt, buildAddPrompt } = require('./prompts');
9
+
10
+ // ── SDK loader (cached, shared across sessions) ──
8
11
 
9
12
  let _sdkModule = null;
10
13
  async function loadSDK() {
@@ -38,6 +41,8 @@ async function loadSDK() {
38
41
  process.exit(1);
39
42
  }
40
43
 
44
+ // ── Helpers ──
45
+
41
46
  function applyEnvConfig(config) {
42
47
  Object.assign(process.env, buildEnvVars(config));
43
48
  }
@@ -63,8 +68,9 @@ function extractResult(messages) {
63
68
  return null;
64
69
  }
65
70
 
66
- function stripAnsi(str) {
67
- return str.replace(/\x1b\[[0-9;]*m/g, '');
71
+ function writeSessionSeparator(logStream, sessionNum, label) {
72
+ const sep = '='.repeat(60);
73
+ logStream.write(`\n${sep}\n[Session ${sessionNum}] ${label} ${new Date().toISOString()}\n${sep}\n`);
68
74
  }
69
75
 
70
76
  function logMessage(message, logStream, indicator) {
@@ -75,17 +81,29 @@ function logMessage(message, logStream, indicator) {
75
81
  const statusLine = indicator.getStatusLine();
76
82
  process.stderr.write('\r\x1b[K');
77
83
  if (statusLine) process.stderr.write(statusLine + '\n');
78
- if (logStream && statusLine) {
79
- logStream.write('\n' + stripAnsi(statusLine) + '\n');
80
- }
81
84
  }
82
85
  process.stdout.write(block.text);
83
86
  if (logStream) logStream.write(block.text);
84
87
  }
88
+ if (block.type === 'tool_use' && logStream) {
89
+ logStream.write(`[TOOL_USE] ${block.name}: ${JSON.stringify(block.input).slice(0, 300)}\n`);
90
+ }
91
+ }
92
+ }
93
+
94
+ if (message.type === 'tool_result' && logStream) {
95
+ const isErr = message.is_error || false;
96
+ const content = typeof message.content === 'string'
97
+ ? message.content.slice(0, 500)
98
+ : JSON.stringify(message.content).slice(0, 500);
99
+ if (isErr) {
100
+ logStream.write(`[TOOL_ERROR] ${content}\n`);
85
101
  }
86
102
  }
87
103
  }
88
104
 
105
+ // ── Session runners ──
106
+
89
107
  async function runCodingSession(sessionNum, opts = {}) {
90
108
  const sdk = await loadSDK();
91
109
  const config = loadConfig();
@@ -99,59 +117,29 @@ async function runCodingSession(sessionNum, opts = {}) {
99
117
  const taskId = opts.taskId || 'unknown';
100
118
  const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
101
119
  const logFile = path.join(p.logsDir, `${taskId}_session_${sessionNum}_${dateStr}.log`);
102
- const activityLogFile = path.join(p.logsDir, `session_${sessionNum}.activity.log`);
103
120
  const logStream = fs.createWriteStream(logFile, { flags: 'a' });
104
121
 
105
- indicator.start(sessionNum, activityLogFile);
122
+ writeSessionSeparator(logStream, sessionNum, `coding task=${taskId}`);
106
123
 
107
- const editCounts = {};
108
- const EDIT_THRESHOLD = 5;
109
124
  const stallTimeoutMs = config.stallTimeout * 1000;
110
- let stallDetected = false;
125
+ const { hooks, cleanup, isStalled } = createSessionHooks(indicator, logStream, {
126
+ enableStallDetection: true,
127
+ stallTimeoutMs,
128
+ enableEditGuard: true,
129
+ });
111
130
 
112
- const stallChecker = setInterval(() => {
113
- const idleMs = Date.now() - indicator.lastToolTime;
114
- if (idleMs > stallTimeoutMs && !stallDetected) {
115
- stallDetected = true;
116
- log('warn', `无新工具调用超过 ${Math.floor(idleMs / 60000)} 分钟,自动中断 session`);
117
- }
118
- }, 30000);
131
+ indicator.start(sessionNum);
119
132
 
120
133
  try {
121
134
  const queryOpts = buildQueryOptions(config, opts);
122
135
  queryOpts.systemPrompt = systemPrompt;
123
- queryOpts.hooks = {
124
- PreToolUse: [{
125
- matcher: '*',
126
- hooks: [async (input) => {
127
- inferPhaseStep(indicator, input.tool_name, input.tool_input);
128
-
129
- const target = input.tool_input?.file_path || input.tool_input?.path || '';
130
- const toolSummary = target ? target.split('/').slice(-2).join('/') : '';
131
- if (toolSummary) {
132
- logStream.write(`[${new Date().toISOString()}] ${input.tool_name}: ${toolSummary}\n`);
133
- }
134
-
135
- if (['Write', 'Edit', 'MultiEdit'].includes(input.tool_name) && target) {
136
- editCounts[target] = (editCounts[target] || 0) + 1;
137
- if (editCounts[target] > EDIT_THRESHOLD) {
138
- return {
139
- decision: 'block',
140
- message: `已对 ${target} 编辑 ${editCounts[target]} 次,疑似死循环。请重新审视方案后再继续。`,
141
- };
142
- }
143
- }
144
-
145
- return {};
146
- }]
147
- }]
148
- };
136
+ queryOpts.hooks = hooks;
149
137
 
150
138
  const session = sdk.query({ prompt, options: queryOpts });
151
139
 
152
140
  const collected = [];
153
141
  for await (const message of session) {
154
- if (stallDetected) {
142
+ if (isStalled()) {
155
143
  log('warn', '停顿超时,中断消息循环');
156
144
  break;
157
145
  }
@@ -159,20 +147,20 @@ async function runCodingSession(sessionNum, opts = {}) {
159
147
  logMessage(message, logStream, indicator);
160
148
  }
161
149
 
162
- clearInterval(stallChecker);
150
+ cleanup();
163
151
  logStream.end();
164
152
  indicator.stop();
165
153
 
166
154
  const result = extractResult(collected);
167
155
  return {
168
- exitCode: stallDetected ? 2 : 0,
156
+ exitCode: isStalled() ? 2 : 0,
169
157
  cost: result?.total_cost_usd ?? null,
170
158
  tokenUsage: result?.usage ?? null,
171
159
  logFile,
172
- stalled: stallDetected,
160
+ stalled: isStalled(),
173
161
  };
174
162
  } catch (err) {
175
- clearInterval(stallChecker);
163
+ cleanup();
176
164
  logStream.end();
177
165
  indicator.stop();
178
166
  log('error', `Claude SDK 错误: ${err.message}`);
@@ -200,40 +188,47 @@ async function runScanSession(requirement, opts = {}) {
200
188
  const logFile = path.join(p.logsDir, `scan_${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.log`);
201
189
  const logStream = fs.createWriteStream(logFile, { flags: 'a' });
202
190
 
191
+ writeSessionSeparator(logStream, 0, `scan (${projectType})`);
192
+
193
+ const stallTimeoutMs = config.stallTimeout * 1000;
194
+ const { hooks, cleanup, isStalled } = createSessionHooks(indicator, logStream, {
195
+ enableStallDetection: true,
196
+ stallTimeoutMs,
197
+ });
198
+
203
199
  indicator.start(0);
204
200
  log('info', `正在调用 Claude Code 执行项目扫描(${projectType}项目)...`);
205
201
 
206
202
  try {
207
203
  const queryOpts = buildQueryOptions(config, opts);
208
204
  queryOpts.systemPrompt = systemPrompt;
209
- queryOpts.hooks = {
210
- PreToolUse: [{
211
- matcher: '*',
212
- hooks: [async (input) => {
213
- inferPhaseStep(indicator, input.tool_name, input.tool_input);
214
- return {};
215
- }]
216
- }]
217
- };
205
+ queryOpts.hooks = hooks;
218
206
 
219
207
  const session = sdk.query({ prompt, options: queryOpts });
220
208
 
221
209
  const collected = [];
222
210
  for await (const message of session) {
211
+ if (isStalled()) {
212
+ log('warn', '扫描停顿超时,中断');
213
+ break;
214
+ }
223
215
  collected.push(message);
224
216
  logMessage(message, logStream, indicator);
225
217
  }
226
218
 
219
+ cleanup();
227
220
  logStream.end();
228
221
  indicator.stop();
229
222
 
230
223
  const result = extractResult(collected);
231
224
  return {
232
- exitCode: 0,
225
+ exitCode: isStalled() ? 2 : 0,
233
226
  cost: result?.total_cost_usd ?? null,
234
227
  logFile,
228
+ stalled: isStalled(),
235
229
  };
236
230
  } catch (err) {
231
+ cleanup();
237
232
  logStream.end();
238
233
  indicator.stop();
239
234
  log('error', `扫描失败: ${err.message}`);
@@ -247,39 +242,45 @@ async function runAddSession(instruction, opts = {}) {
247
242
  applyEnvConfig(config);
248
243
  const indicator = new Indicator();
249
244
 
250
- const systemPrompt = buildSystemPrompt(false);
245
+ const systemPrompt = buildAddSystemPrompt();
251
246
  const prompt = buildAddPrompt(instruction);
252
247
 
253
248
  const p = paths();
254
249
  const logFile = path.join(p.logsDir, `add_tasks_${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.log`);
255
250
  const logStream = fs.createWriteStream(logFile, { flags: 'a' });
256
251
 
252
+ writeSessionSeparator(logStream, 0, 'add tasks');
253
+
254
+ const stallTimeoutMs = config.stallTimeout * 1000;
255
+ const { hooks, cleanup, isStalled } = createSessionHooks(indicator, logStream, {
256
+ enableStallDetection: true,
257
+ stallTimeoutMs,
258
+ });
259
+
257
260
  indicator.start(0);
258
261
  log('info', '正在追加任务...');
259
262
 
260
263
  try {
261
264
  const queryOpts = buildQueryOptions(config, opts);
262
265
  queryOpts.systemPrompt = systemPrompt;
263
- queryOpts.hooks = {
264
- PreToolUse: [{
265
- matcher: '*',
266
- hooks: [async (input) => {
267
- inferPhaseStep(indicator, input.tool_name, input.tool_input);
268
- return {};
269
- }]
270
- }]
271
- };
266
+ queryOpts.hooks = hooks;
272
267
 
273
268
  const session = sdk.query({ prompt, options: queryOpts });
274
269
 
275
270
  for await (const message of session) {
271
+ if (isStalled()) {
272
+ log('warn', '追加任务停顿超时,中断');
273
+ break;
274
+ }
276
275
  logMessage(message, logStream, indicator);
277
276
  }
278
277
 
278
+ cleanup();
279
279
  logStream.end();
280
280
  indicator.stop();
281
281
  log('ok', '任务追加完成');
282
282
  } catch (err) {
283
+ cleanup();
283
284
  logStream.end();
284
285
  indicator.stop();
285
286
  log('error', `任务追加失败: ${err.message}`);
@@ -303,6 +304,7 @@ function hasCodeFiles(projectRoot) {
303
304
  }
304
305
 
305
306
  module.exports = {
307
+ loadSDK,
306
308
  runCodingSession,
307
309
  runScanSession,
308
310
  runAddSession,
package/src/tasks.js CHANGED
@@ -67,6 +67,22 @@ function setStatus(data, taskId, newStatus) {
67
67
  return task;
68
68
  }
69
69
 
70
+ /**
71
+ * Harness-level forced status change (bypasses TRANSITIONS validation).
72
+ * Used when harness needs to mark tasks failed after max retries.
73
+ */
74
+ function forceStatus(data, status) {
75
+ const features = getFeatures(data);
76
+ for (const f of features) {
77
+ if (f.status === 'in_progress') {
78
+ f.status = status;
79
+ saveTasks(data);
80
+ return f;
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+
70
86
  function addTask(data, task) {
71
87
  if (!data) {
72
88
  data = { project: '', created_at: new Date().toISOString().slice(0, 10), features: [] };
@@ -145,6 +161,7 @@ module.exports = {
145
161
  getFeatures,
146
162
  findNextTask,
147
163
  setStatus,
164
+ forceStatus,
148
165
  addTask,
149
166
  getStats,
150
167
  showStatus,
@@ -177,8 +177,8 @@ pending ──→ in_progress ──→ testing ──→ done
177
177
  ### 第一步:恢复上下文
178
178
 
179
179
  1. **检查 prompt 注入的上下文**:
180
- - 如果 prompt 中包含"任务上下文"(Hint 7),说明 harness 已注入当前任务信息,**跳过读取 tasks.json**,直接确认任务后进入第二步
181
- - 如果 prompt 中包含"上次会话"(Hint 8),说明 harness 已注入上次会话摘要,**跳过读取 session_result.json 历史**
180
+ - 如果 prompt 中包含"任务上下文"(Hint 6),说明 harness 已注入当前任务信息,**跳过读取 tasks.json**,直接确认任务后进入第二步
181
+ - 如果 prompt 中包含"上次会话"(Hint 7),说明 harness 已注入上次会话摘要,**跳过读取 session_result.json 历史**
182
182
  2. 批量读取以下文件(一次工具调用,跳过已注入的):`.claude-coder/project_profile.json`、`.claude-coder/tasks.json`(仅当无 Hint 6 时)
183
183
  3. 如果无 Hint 7 且 `session_result.json` 不存在,运行 `git log --oneline -20` 补充上下文
184
184
  4. 如果项目根目录存在 `requirements.md`,读取用户的详细需求和偏好(技术约束、样式要求等),作为本次会话的参考依据