claude-coder 1.5.1 → 1.5.3

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.
@@ -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` | 项目初始化扫描 |
@@ -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
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-coder",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
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
@@ -16,6 +16,8 @@ class Indicator {
16
16
  this.lastToolTime = Date.now();
17
17
  this.sessionNum = 0;
18
18
  this.startTime = Date.now();
19
+ this._lastContentKey = '';
20
+ this._lastRenderTime = 0;
19
21
  }
20
22
 
21
23
  start(sessionNum) {
@@ -86,8 +88,16 @@ class Indicator {
86
88
 
87
89
  _render() {
88
90
  this.spinnerIndex++;
89
- const line = this.getStatusLine();
91
+ const contentKey = `${this.phase}|${this.step}|${this.toolTarget}`;
92
+ const now = Date.now();
93
+
94
+ if (contentKey === this._lastContentKey && now - this._lastRenderTime < 5000) {
95
+ return;
96
+ }
97
+ this._lastContentKey = contentKey;
98
+ this._lastRenderTime = now;
90
99
 
100
+ const line = this.getStatusLine();
91
101
  const maxWidth = process.stderr.columns || 80;
92
102
  const truncated = line.length > maxWidth + 20 ? line.slice(0, maxWidth + 20) : line;
93
103
 
@@ -95,41 +105,60 @@ class Indicator {
95
105
  }
96
106
  }
97
107
 
98
- // Phase-signal logic: infer phase/step from tool calls
108
+ function extractFileTarget(toolInput) {
109
+ const raw = typeof toolInput === 'object'
110
+ ? (toolInput.file_path || toolInput.path || '')
111
+ : '';
112
+ if (!raw) return '';
113
+ return raw.split('/').slice(-2).join('/').slice(0, 40);
114
+ }
115
+
116
+ function extractBashLabel(cmd) {
117
+ if (cmd.includes('git ')) return 'Git 操作';
118
+ if (cmd.includes('npm ') || cmd.includes('pip ') || cmd.includes('pnpm ') || cmd.includes('yarn ')) return '安装依赖';
119
+ if (cmd.includes('curl') || cmd.includes('pytest') || cmd.includes('jest') || /\btest\b/.test(cmd)) return '测试验证';
120
+ if (cmd.includes('python ') || cmd.includes('node ')) return '执行脚本';
121
+ return '执行命令';
122
+ }
123
+
124
+ function extractBashTarget(cmd) {
125
+ let clean = cmd.replace(/^(?:cd\s+\S+\s*&&\s*)+/g, '').trim();
126
+ clean = clean.split(/\s*(?:\|{1,2}|;|&&|2>&1|>\s*\/dev\/null)\s*/)[0].trim();
127
+ return clean.slice(0, 40);
128
+ }
129
+
99
130
  function inferPhaseStep(indicator, toolName, toolInput) {
100
131
  const name = (toolName || '').toLowerCase();
101
132
 
102
133
  indicator.lastToolTime = Date.now();
103
134
 
104
- const rawTarget = typeof toolInput === 'object'
105
- ? (toolInput.file_path || toolInput.path || toolInput.command || toolInput.pattern || '')
106
- : String(toolInput || '');
107
- const shortTarget = rawTarget.split('/').slice(-2).join('/').slice(0, 40);
108
- indicator.toolTarget = shortTarget;
109
-
110
135
  if (name === 'write' || name === 'edit' || name === 'multiedit' || name === 'str_replace_editor' || name === 'strreplace') {
111
136
  indicator.updatePhase('coding');
137
+ indicator.updateStep('编辑文件');
138
+ indicator.toolTarget = extractFileTarget(toolInput);
112
139
  } else if (name === 'bash' || name === 'shell') {
113
140
  const cmd = typeof toolInput === 'object' ? (toolInput.command || '') : String(toolInput || '');
114
- if (cmd.includes('git ')) {
115
- indicator.updateStep('Git 操作');
116
- } else if (cmd.includes('npm ') || cmd.includes('pip ') || cmd.includes('pnpm ')) {
117
- indicator.updateStep('安装依赖');
118
- } else if (cmd.includes('test') || cmd.includes('curl') || cmd.includes('pytest')) {
119
- indicator.updateStep('测试验证');
120
- indicator.updatePhase('coding');
121
- } else {
141
+ const label = extractBashLabel(cmd);
142
+ indicator.updateStep(label);
143
+ indicator.toolTarget = extractBashTarget(cmd);
144
+ if (label === '测试验证' || label === '执行脚本' || label === '执行命令') {
122
145
  indicator.updatePhase('coding');
123
146
  }
124
147
  } else if (name === 'read' || name === 'glob' || name === 'grep' || name === 'ls') {
125
148
  indicator.updatePhase('thinking');
126
149
  indicator.updateStep('读取文件');
150
+ indicator.toolTarget = extractFileTarget(toolInput);
127
151
  } else if (name === 'task') {
128
152
  indicator.updatePhase('thinking');
129
153
  indicator.updateStep('子 Agent 搜索');
154
+ indicator.toolTarget = '';
130
155
  } else if (name === 'websearch' || name === 'webfetch') {
131
156
  indicator.updatePhase('thinking');
132
157
  indicator.updateStep('查阅文档');
158
+ indicator.toolTarget = '';
159
+ } else {
160
+ indicator.updateStep('工具调用');
161
+ indicator.toolTarget = '';
133
162
  }
134
163
 
135
164
  let summary;
@@ -137,15 +166,7 @@ function inferPhaseStep(indicator, toolName, toolInput) {
137
166
  const target = toolInput.file_path || toolInput.path || '';
138
167
  const cmd = toolInput.command || '';
139
168
  const pattern = toolInput.pattern || '';
140
- if (target) {
141
- summary = target;
142
- } else if (cmd) {
143
- summary = cmd.slice(0, 200);
144
- } else if (pattern) {
145
- summary = `pattern: ${pattern}`;
146
- } else {
147
- summary = JSON.stringify(toolInput).slice(0, 200);
148
- }
169
+ summary = target || (cmd ? cmd.slice(0, 200) : '') || (pattern ? `pattern: ${pattern}` : JSON.stringify(toolInput).slice(0, 200));
149
170
  } else {
150
171
  summary = String(toolInput || '').slice(0, 200);
151
172
  }
package/src/prompts.js CHANGED
@@ -209,6 +209,15 @@ 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.
214
223
  * Structure: Role (primacy) → Context → CoT → TaskGuide → Instruction (recency)
@@ -276,7 +285,7 @@ function buildAddPrompt(instruction) {
276
285
  '5. 分解任务:每个任务对应一个独立可测试的功能单元,description 简明(40字内),steps 具体可操作',
277
286
  '6. 追加到 tasks.json,id 和 priority 从已有最大值递增,status: pending',
278
287
  '7. git add -A && git commit -m "chore: add new tasks"',
279
- '8. 写入 session_result.json',
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 个任务:简述" })',
280
289
  '',
281
290
 
282
291
  // --- Quality constraints ---
@@ -295,5 +304,6 @@ module.exports = {
295
304
  buildCodingPrompt,
296
305
  buildTaskGuide,
297
306
  buildScanPrompt,
307
+ buildAddSystemPrompt,
298
308
  buildAddPrompt,
299
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,29 +68,48 @@ 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
 
76
+ let _lastPrintedStatusKey = '';
77
+
70
78
  function logMessage(message, logStream, indicator) {
71
79
  if (message.type === 'assistant' && message.message?.content) {
72
80
  for (const block of message.message.content) {
73
81
  if (block.type === 'text' && block.text) {
74
82
  if (indicator) {
75
- const statusLine = indicator.getStatusLine();
76
83
  process.stderr.write('\r\x1b[K');
77
- if (statusLine) process.stderr.write(statusLine + '\n');
78
- if (logStream && statusLine) {
79
- logStream.write('\n' + stripAnsi(statusLine) + '\n');
84
+ const contentKey = `${indicator.phase}|${indicator.step}|${indicator.toolTarget}`;
85
+ if (contentKey !== _lastPrintedStatusKey) {
86
+ _lastPrintedStatusKey = contentKey;
87
+ const statusLine = indicator.getStatusLine();
88
+ if (statusLine) process.stderr.write(statusLine + '\n');
80
89
  }
81
90
  }
82
91
  process.stdout.write(block.text);
83
92
  if (logStream) logStream.write(block.text);
84
93
  }
94
+ if (block.type === 'tool_use' && logStream) {
95
+ logStream.write(`[TOOL_USE] ${block.name}: ${JSON.stringify(block.input).slice(0, 300)}\n`);
96
+ }
97
+ }
98
+ }
99
+
100
+ if (message.type === 'tool_result' && logStream) {
101
+ const isErr = message.is_error || false;
102
+ const content = typeof message.content === 'string'
103
+ ? message.content.slice(0, 500)
104
+ : JSON.stringify(message.content).slice(0, 500);
105
+ if (isErr) {
106
+ logStream.write(`[TOOL_ERROR] ${content}\n`);
85
107
  }
86
108
  }
87
109
  }
88
110
 
111
+ // ── Session runners ──
112
+
89
113
  async function runCodingSession(sessionNum, opts = {}) {
90
114
  const sdk = await loadSDK();
91
115
  const config = loadConfig();
@@ -101,58 +125,27 @@ async function runCodingSession(sessionNum, opts = {}) {
101
125
  const logFile = path.join(p.logsDir, `${taskId}_session_${sessionNum}_${dateStr}.log`);
102
126
  const logStream = fs.createWriteStream(logFile, { flags: 'a' });
103
127
 
104
- indicator.start(sessionNum);
128
+ writeSessionSeparator(logStream, sessionNum, `coding task=${taskId}`);
105
129
 
106
- const editCounts = {};
107
- const EDIT_THRESHOLD = 5;
108
130
  const stallTimeoutMs = config.stallTimeout * 1000;
109
- let stallDetected = false;
131
+ const { hooks, cleanup, isStalled } = createSessionHooks(indicator, logStream, {
132
+ enableStallDetection: true,
133
+ stallTimeoutMs,
134
+ enableEditGuard: true,
135
+ });
110
136
 
111
- const stallChecker = setInterval(() => {
112
- const idleMs = Date.now() - indicator.lastToolTime;
113
- if (idleMs > stallTimeoutMs && !stallDetected) {
114
- stallDetected = true;
115
- log('warn', `无新工具调用超过 ${Math.floor(idleMs / 60000)} 分钟,自动中断 session`);
116
- }
117
- }, 30000);
137
+ indicator.start(sessionNum);
118
138
 
119
139
  try {
120
140
  const queryOpts = buildQueryOptions(config, opts);
121
141
  queryOpts.systemPrompt = systemPrompt;
122
- queryOpts.hooks = {
123
- PreToolUse: [{
124
- matcher: '*',
125
- hooks: [async (input) => {
126
- inferPhaseStep(indicator, input.tool_name, input.tool_input);
127
-
128
- const target = input.tool_input?.file_path || input.tool_input?.path || '';
129
- const cmd = input.tool_input?.command || '';
130
- const pattern = input.tool_input?.pattern || '';
131
- const detail = target || cmd.slice(0, 200) || (pattern ? `pattern: ${pattern}` : '');
132
- if (detail) {
133
- logStream.write(`[${new Date().toISOString()}] ${input.tool_name}: ${detail}\n`);
134
- }
135
-
136
- if (['Write', 'Edit', 'MultiEdit'].includes(input.tool_name) && target) {
137
- editCounts[target] = (editCounts[target] || 0) + 1;
138
- if (editCounts[target] > EDIT_THRESHOLD) {
139
- return {
140
- decision: 'block',
141
- message: `已对 ${target} 编辑 ${editCounts[target]} 次,疑似死循环。请重新审视方案后再继续。`,
142
- };
143
- }
144
- }
145
-
146
- return {};
147
- }]
148
- }]
149
- };
142
+ queryOpts.hooks = hooks;
150
143
 
151
144
  const session = sdk.query({ prompt, options: queryOpts });
152
145
 
153
146
  const collected = [];
154
147
  for await (const message of session) {
155
- if (stallDetected) {
148
+ if (isStalled()) {
156
149
  log('warn', '停顿超时,中断消息循环');
157
150
  break;
158
151
  }
@@ -160,20 +153,20 @@ async function runCodingSession(sessionNum, opts = {}) {
160
153
  logMessage(message, logStream, indicator);
161
154
  }
162
155
 
163
- clearInterval(stallChecker);
156
+ cleanup();
164
157
  logStream.end();
165
158
  indicator.stop();
166
159
 
167
160
  const result = extractResult(collected);
168
161
  return {
169
- exitCode: stallDetected ? 2 : 0,
162
+ exitCode: isStalled() ? 2 : 0,
170
163
  cost: result?.total_cost_usd ?? null,
171
164
  tokenUsage: result?.usage ?? null,
172
165
  logFile,
173
- stalled: stallDetected,
166
+ stalled: isStalled(),
174
167
  };
175
168
  } catch (err) {
176
- clearInterval(stallChecker);
169
+ cleanup();
177
170
  logStream.end();
178
171
  indicator.stop();
179
172
  log('error', `Claude SDK 错误: ${err.message}`);
@@ -201,40 +194,47 @@ async function runScanSession(requirement, opts = {}) {
201
194
  const logFile = path.join(p.logsDir, `scan_${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.log`);
202
195
  const logStream = fs.createWriteStream(logFile, { flags: 'a' });
203
196
 
197
+ writeSessionSeparator(logStream, 0, `scan (${projectType})`);
198
+
199
+ const stallTimeoutMs = config.stallTimeout * 1000;
200
+ const { hooks, cleanup, isStalled } = createSessionHooks(indicator, logStream, {
201
+ enableStallDetection: true,
202
+ stallTimeoutMs,
203
+ });
204
+
204
205
  indicator.start(0);
205
206
  log('info', `正在调用 Claude Code 执行项目扫描(${projectType}项目)...`);
206
207
 
207
208
  try {
208
209
  const queryOpts = buildQueryOptions(config, opts);
209
210
  queryOpts.systemPrompt = systemPrompt;
210
- queryOpts.hooks = {
211
- PreToolUse: [{
212
- matcher: '*',
213
- hooks: [async (input) => {
214
- inferPhaseStep(indicator, input.tool_name, input.tool_input);
215
- return {};
216
- }]
217
- }]
218
- };
211
+ queryOpts.hooks = hooks;
219
212
 
220
213
  const session = sdk.query({ prompt, options: queryOpts });
221
214
 
222
215
  const collected = [];
223
216
  for await (const message of session) {
217
+ if (isStalled()) {
218
+ log('warn', '扫描停顿超时,中断');
219
+ break;
220
+ }
224
221
  collected.push(message);
225
222
  logMessage(message, logStream, indicator);
226
223
  }
227
224
 
225
+ cleanup();
228
226
  logStream.end();
229
227
  indicator.stop();
230
228
 
231
229
  const result = extractResult(collected);
232
230
  return {
233
- exitCode: 0,
231
+ exitCode: isStalled() ? 2 : 0,
234
232
  cost: result?.total_cost_usd ?? null,
235
233
  logFile,
234
+ stalled: isStalled(),
236
235
  };
237
236
  } catch (err) {
237
+ cleanup();
238
238
  logStream.end();
239
239
  indicator.stop();
240
240
  log('error', `扫描失败: ${err.message}`);
@@ -248,39 +248,45 @@ async function runAddSession(instruction, opts = {}) {
248
248
  applyEnvConfig(config);
249
249
  const indicator = new Indicator();
250
250
 
251
- const systemPrompt = buildSystemPrompt(false);
251
+ const systemPrompt = buildAddSystemPrompt();
252
252
  const prompt = buildAddPrompt(instruction);
253
253
 
254
254
  const p = paths();
255
255
  const logFile = path.join(p.logsDir, `add_tasks_${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.log`);
256
256
  const logStream = fs.createWriteStream(logFile, { flags: 'a' });
257
257
 
258
+ writeSessionSeparator(logStream, 0, 'add tasks');
259
+
260
+ const stallTimeoutMs = config.stallTimeout * 1000;
261
+ const { hooks, cleanup, isStalled } = createSessionHooks(indicator, logStream, {
262
+ enableStallDetection: true,
263
+ stallTimeoutMs,
264
+ });
265
+
258
266
  indicator.start(0);
259
267
  log('info', '正在追加任务...');
260
268
 
261
269
  try {
262
270
  const queryOpts = buildQueryOptions(config, opts);
263
271
  queryOpts.systemPrompt = systemPrompt;
264
- queryOpts.hooks = {
265
- PreToolUse: [{
266
- matcher: '*',
267
- hooks: [async (input) => {
268
- inferPhaseStep(indicator, input.tool_name, input.tool_input);
269
- return {};
270
- }]
271
- }]
272
- };
272
+ queryOpts.hooks = hooks;
273
273
 
274
274
  const session = sdk.query({ prompt, options: queryOpts });
275
275
 
276
276
  for await (const message of session) {
277
+ if (isStalled()) {
278
+ log('warn', '追加任务停顿超时,中断');
279
+ break;
280
+ }
277
281
  logMessage(message, logStream, indicator);
278
282
  }
279
283
 
284
+ cleanup();
280
285
  logStream.end();
281
286
  indicator.stop();
282
287
  log('ok', '任务追加完成');
283
288
  } catch (err) {
289
+ cleanup();
284
290
  logStream.end();
285
291
  indicator.stop();
286
292
  log('error', `任务追加失败: ${err.message}`);
@@ -304,6 +310,7 @@ function hasCodeFiles(projectRoot) {
304
310
  }
305
311
 
306
312
  module.exports = {
313
+ loadSDK,
307
314
  runCodingSession,
308
315
  runScanSession,
309
316
  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`,读取用户的详细需求和偏好(技术约束、样式要求等),作为本次会话的参考依据