claude-coder 1.5.6 → 1.6.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 +3 -2
- package/bin/cli.js +27 -2
- package/docs/ARCHITECTURE.md +8 -8
- package/docs/PLAYWRIGHT_CREDENTIALS.md +123 -97
- package/docs/README.en.md +3 -2
- package/package.json +4 -2
- package/src/auth.js +171 -38
- package/src/config.js +20 -4
- package/src/hooks.js +13 -3
- package/src/indicator.js +19 -37
- package/src/prompts.js +38 -10
- package/src/runner.js +14 -2
- package/src/session.js +5 -3
- package/src/setup.js +37 -25
- package/src/tasks.js +5 -1
- package/src/validator.js +66 -38
- package/templates/CLAUDE.md +13 -40
- package/templates/SCAN_PROTOCOL.md +4 -4
- package/templates/test_rule.md +158 -0
- package/docs/PHASE_INJECTION_RESEARCH.md +0 -325
package/src/auth.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
const { execSync } = require('child_process');
|
|
6
|
-
const { paths, log, getProjectRoot, ensureLoopDir } = require('./config');
|
|
7
|
+
const { paths, loadConfig, log, getProjectRoot, ensureLoopDir } = require('./config');
|
|
7
8
|
|
|
8
9
|
function updateGitignore(entry) {
|
|
9
10
|
const gitignorePath = path.join(getProjectRoot(), '.gitignore');
|
|
@@ -18,30 +19,35 @@ function updateGitignore(entry) {
|
|
|
18
19
|
log('ok', `.gitignore 已添加: ${entry}`);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
function updateMcpConfig(
|
|
22
|
-
const p = paths();
|
|
22
|
+
function updateMcpConfig(p, mode) {
|
|
23
23
|
let mcpConfig = {};
|
|
24
24
|
if (fs.existsSync(p.mcpConfig)) {
|
|
25
|
-
try {
|
|
26
|
-
mcpConfig = JSON.parse(fs.readFileSync(p.mcpConfig, 'utf8'));
|
|
27
|
-
} catch {
|
|
28
|
-
log('warn', '.mcp.json 解析失败,将覆盖');
|
|
29
|
-
}
|
|
25
|
+
try { mcpConfig = JSON.parse(fs.readFileSync(p.mcpConfig, 'utf8')); } catch {}
|
|
30
26
|
}
|
|
31
27
|
|
|
32
28
|
if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
|
|
33
29
|
|
|
34
|
-
const
|
|
35
|
-
mcpConfig.mcpServers.playwright = {
|
|
36
|
-
command: 'npx',
|
|
37
|
-
args: [
|
|
38
|
-
'@playwright/mcp@latest',
|
|
39
|
-
`--user-data-dir=${relProfileDir}`,
|
|
40
|
-
],
|
|
41
|
-
};
|
|
30
|
+
const args = ['@playwright/mcp@latest'];
|
|
42
31
|
|
|
32
|
+
switch (mode) {
|
|
33
|
+
case 'persistent': {
|
|
34
|
+
const relProfile = path.relative(getProjectRoot(), p.browserProfile).split(path.sep).join('/');
|
|
35
|
+
args.push(`--user-data-dir=${relProfile}`);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case 'isolated': {
|
|
39
|
+
const relAuth = path.relative(getProjectRoot(), p.playwrightAuth).split(path.sep).join('/');
|
|
40
|
+
args.push('--isolated', `--storage-state=${relAuth}`);
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
case 'extension':
|
|
44
|
+
args.push('--extension');
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
mcpConfig.mcpServers.playwright = { command: 'npx', args };
|
|
43
49
|
fs.writeFileSync(p.mcpConfig, JSON.stringify(mcpConfig, null, 2) + '\n', 'utf8');
|
|
44
|
-
log('ok', `.mcp.json 已配置 Playwright MCP (
|
|
50
|
+
log('ok', `.mcp.json 已配置 Playwright MCP (${mode} 模式)`);
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
function enableMcpPlaywrightEnv() {
|
|
@@ -59,33 +65,108 @@ function enableMcpPlaywrightEnv() {
|
|
|
59
65
|
log('ok', '.claude-coder/.env 已设置 MCP_PLAYWRIGHT=true');
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
ensureLoopDir();
|
|
64
|
-
const p = paths();
|
|
65
|
-
const targetUrl = url || 'http://localhost:3000';
|
|
68
|
+
// ── persistent 模式:启动持久化浏览器让用户登录 ──
|
|
66
69
|
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
async function authPersistent(url, p) {
|
|
71
|
+
const profileDir = p.browserProfile;
|
|
72
|
+
if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
const lockFile = path.join(profileDir, 'SingletonLock');
|
|
75
|
+
if (fs.existsSync(lockFile)) {
|
|
76
|
+
fs.unlinkSync(lockFile);
|
|
77
|
+
log('warn', '已清理残留的 SingletonLock(上次浏览器未正常关闭)');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log('操作步骤:');
|
|
81
|
+
console.log(' 1. 浏览器将自动打开,请手动完成登录');
|
|
82
|
+
console.log(' 2. 登录成功后关闭浏览器窗口');
|
|
83
|
+
console.log(' 3. 登录状态将保存在持久化配置中');
|
|
84
|
+
console.log(' 4. MCP 后续会话自动复用此登录状态');
|
|
73
85
|
console.log('');
|
|
86
|
+
|
|
87
|
+
const scriptContent = [
|
|
88
|
+
`let chromium;`,
|
|
89
|
+
`try { chromium = require('playwright').chromium; } catch {`,
|
|
90
|
+
` try { chromium = require('@playwright/test').chromium; } catch {`,
|
|
91
|
+
` console.error('错误: 未找到 playwright 模块');`,
|
|
92
|
+
` console.error('请安装: npx playwright install chromium');`,
|
|
93
|
+
` process.exit(1);`,
|
|
94
|
+
` }`,
|
|
95
|
+
`}`,
|
|
96
|
+
`(async () => {`,
|
|
97
|
+
` const ctx = await chromium.launchPersistentContext(${JSON.stringify(profileDir)}, { headless: false });`,
|
|
98
|
+
` const page = ctx.pages()[0] || await ctx.newPage();`,
|
|
99
|
+
` try { await page.goto(${JSON.stringify(url)}); } catch {}`,
|
|
100
|
+
` console.log('请在浏览器中完成登录后关闭窗口...');`,
|
|
101
|
+
` await new Promise(r => {`,
|
|
102
|
+
` ctx.on('close', r);`,
|
|
103
|
+
` const t = setInterval(() => { try { if (!ctx.pages().length) { clearInterval(t); r(); } } catch { clearInterval(t); r(); } }, 2000);`,
|
|
104
|
+
` });`,
|
|
105
|
+
` try { await ctx.close(); } catch {}`,
|
|
106
|
+
`})().then(() => process.exit(0)).catch(() => process.exit(0));`,
|
|
107
|
+
].join('\n');
|
|
108
|
+
|
|
109
|
+
const tmpScript = path.join(os.tmpdir(), `pw-auth-${Date.now()}.js`);
|
|
110
|
+
fs.writeFileSync(tmpScript, scriptContent);
|
|
111
|
+
|
|
112
|
+
const helperModules = path.join(__dirname, '..', 'node_modules');
|
|
113
|
+
const existingNodePath = process.env.NODE_PATH || '';
|
|
114
|
+
const nodePath = existingNodePath ? `${helperModules}:${existingNodePath}` : helperModules;
|
|
115
|
+
|
|
116
|
+
let scriptOk = false;
|
|
117
|
+
try {
|
|
118
|
+
execSync(`node "${tmpScript}"`, {
|
|
119
|
+
stdio: 'inherit',
|
|
120
|
+
cwd: getProjectRoot(),
|
|
121
|
+
env: { ...process.env, NODE_PATH: nodePath },
|
|
122
|
+
});
|
|
123
|
+
scriptOk = true;
|
|
124
|
+
} catch {
|
|
125
|
+
// 浏览器关闭时可能返回非零退出码,只要 profile 目录有内容就认为成功
|
|
126
|
+
const profileFiles = fs.readdirSync(profileDir);
|
|
127
|
+
scriptOk = profileFiles.length > 2;
|
|
128
|
+
if (!scriptOk) {
|
|
129
|
+
log('error', 'Playwright 启动失败,且未检测到有效的浏览器配置');
|
|
130
|
+
log('info', '请确保已安装 Chromium: npx playwright install chromium');
|
|
131
|
+
try { fs.unlinkSync(tmpScript); } catch {}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
log('warn', '浏览器退出码非零,但已检测到有效配置,继续...');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try { fs.unlinkSync(tmpScript); } catch {}
|
|
138
|
+
|
|
139
|
+
log('ok', '登录状态已保存到持久化配置');
|
|
140
|
+
updateMcpConfig(p, 'persistent');
|
|
141
|
+
updateGitignore('.claude-coder/.runtime/browser-profile');
|
|
142
|
+
enableMcpPlaywrightEnv();
|
|
143
|
+
|
|
144
|
+
console.log('');
|
|
145
|
+
log('ok', '配置完成!');
|
|
146
|
+
const relProfile = path.relative(getProjectRoot(), profileDir);
|
|
147
|
+
log('info', `MCP 使用 persistent 模式 (user-data-dir: ${relProfile})`);
|
|
148
|
+
log('info', '如需更新登录状态,重新运行 claude-coder auth');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── isolated 模式:使用 codegen 录制 storage-state ──
|
|
152
|
+
|
|
153
|
+
async function authIsolated(url, p) {
|
|
74
154
|
console.log('操作步骤:');
|
|
75
155
|
console.log(' 1. 浏览器将自动打开,请手动完成登录');
|
|
76
156
|
console.log(' 2. 登录成功后关闭浏览器窗口');
|
|
77
|
-
console.log(' 3. 登录状态(cookies + localStorage
|
|
157
|
+
console.log(' 3. 登录状态(cookies + localStorage)将保存到 playwright-auth.json');
|
|
158
|
+
console.log(' 4. MCP 每次会话自动从此文件加载初始状态');
|
|
78
159
|
console.log('');
|
|
79
160
|
|
|
80
161
|
try {
|
|
81
162
|
execSync(
|
|
82
|
-
`npx playwright codegen --save-storage="${p.playwrightAuth}" "${
|
|
163
|
+
`npx playwright codegen --save-storage="${p.playwrightAuth}" "${url}"`,
|
|
83
164
|
{ stdio: 'inherit', cwd: getProjectRoot() }
|
|
84
165
|
);
|
|
85
166
|
} catch (err) {
|
|
86
167
|
if (!fs.existsSync(p.playwrightAuth)) {
|
|
87
168
|
log('error', `Playwright 登录状态导出失败: ${err.message}`);
|
|
88
|
-
log('info', '
|
|
169
|
+
log('info', '请确保已安装: npx playwright install chromium');
|
|
89
170
|
return;
|
|
90
171
|
}
|
|
91
172
|
}
|
|
@@ -95,18 +176,70 @@ async function auth(url) {
|
|
|
95
176
|
return;
|
|
96
177
|
}
|
|
97
178
|
|
|
98
|
-
log('ok', '
|
|
99
|
-
|
|
100
|
-
updateMcpConfig(p.browserProfile);
|
|
179
|
+
log('ok', '登录状态已保存到 playwright-auth.json');
|
|
180
|
+
updateMcpConfig(p, 'isolated');
|
|
101
181
|
updateGitignore('.claude-coder/playwright-auth.json');
|
|
102
|
-
updateGitignore('.claude-coder/browser-profile/');
|
|
103
182
|
enableMcpPlaywrightEnv();
|
|
104
183
|
|
|
105
184
|
console.log('');
|
|
106
|
-
log('ok', '
|
|
107
|
-
log('info', 'MCP 使用
|
|
108
|
-
log('info', '
|
|
109
|
-
log('info', '
|
|
185
|
+
log('ok', '配置完成!');
|
|
186
|
+
log('info', 'MCP 使用 isolated 模式 (storage-state)');
|
|
187
|
+
log('info', 'cookies 和 localStorage 每次会话自动从 playwright-auth.json 加载');
|
|
188
|
+
log('info', '如需更新登录状态,重新运行 claude-coder auth');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── extension 模式:连接真实浏览器 ──
|
|
192
|
+
|
|
193
|
+
function authExtension(p) {
|
|
194
|
+
console.log('Extension 模式说明:');
|
|
195
|
+
console.log('');
|
|
196
|
+
console.log(' 此模式通过 Chrome 扩展连接到您正在运行的浏览器。');
|
|
197
|
+
console.log(' MCP 将直接使用浏览器中已有的登录态和扩展。');
|
|
198
|
+
console.log('');
|
|
199
|
+
console.log(' 前置条件:');
|
|
200
|
+
console.log(' 1. 安装 "Playwright MCP Bridge" Chrome/Edge 扩展');
|
|
201
|
+
console.log(' https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm');
|
|
202
|
+
console.log(' 2. 确保浏览器已启动且扩展已启用');
|
|
203
|
+
console.log(' 3. 无需额外认证操作,您的浏览器登录态将自动可用');
|
|
204
|
+
console.log('');
|
|
205
|
+
|
|
206
|
+
updateMcpConfig(p, 'extension');
|
|
207
|
+
enableMcpPlaywrightEnv();
|
|
208
|
+
|
|
209
|
+
console.log('');
|
|
210
|
+
log('ok', '配置完成!');
|
|
211
|
+
log('info', 'MCP 使用 extension 模式(连接真实浏览器)');
|
|
212
|
+
log('info', '确保 Chrome/Edge 已运行且 Playwright MCP Bridge 扩展已启用');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 主入口 ──
|
|
216
|
+
|
|
217
|
+
async function auth(url) {
|
|
218
|
+
ensureLoopDir();
|
|
219
|
+
const config = loadConfig();
|
|
220
|
+
const p = paths();
|
|
221
|
+
const mode = config.playwrightMode;
|
|
222
|
+
const targetUrl = url || 'http://localhost:3000';
|
|
223
|
+
|
|
224
|
+
log('info', `Playwright 模式: ${mode}`);
|
|
225
|
+
log('info', `目标 URL: ${targetUrl}`);
|
|
226
|
+
console.log('');
|
|
227
|
+
|
|
228
|
+
switch (mode) {
|
|
229
|
+
case 'persistent':
|
|
230
|
+
await authPersistent(targetUrl, p);
|
|
231
|
+
break;
|
|
232
|
+
case 'isolated':
|
|
233
|
+
await authIsolated(targetUrl, p);
|
|
234
|
+
break;
|
|
235
|
+
case 'extension':
|
|
236
|
+
authExtension(p);
|
|
237
|
+
break;
|
|
238
|
+
default:
|
|
239
|
+
log('error', `未知的 Playwright 模式: ${mode}`);
|
|
240
|
+
log('info', '请运行 claude-coder setup 重新配置');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
110
243
|
}
|
|
111
244
|
|
|
112
|
-
module.exports = { auth };
|
|
245
|
+
module.exports = { auth, updateMcpConfig };
|
package/src/config.js
CHANGED
|
@@ -57,13 +57,12 @@ function paths() {
|
|
|
57
57
|
testsFile: path.join(loopDir, 'tests.json'),
|
|
58
58
|
testEnvFile: path.join(loopDir, 'test.env'),
|
|
59
59
|
playwrightAuth: path.join(loopDir, 'playwright-auth.json'),
|
|
60
|
-
browserProfile: path.join(
|
|
60
|
+
browserProfile: path.join(runtime, 'browser-profile'),
|
|
61
61
|
mcpConfig: path.join(getProjectRoot(), '.mcp.json'),
|
|
62
62
|
claudeMd: getTemplatePath('CLAUDE.md'),
|
|
63
63
|
scanProtocol: getTemplatePath('SCAN_PROTOCOL.md'),
|
|
64
|
+
testRuleTemplate: getTemplatePath('test_rule.md'),
|
|
64
65
|
runtime,
|
|
65
|
-
phaseFile: path.join(runtime, 'phase'),
|
|
66
|
-
stepFile: path.join(runtime, 'step'),
|
|
67
66
|
logsDir: path.join(runtime, 'logs'),
|
|
68
67
|
};
|
|
69
68
|
}
|
|
@@ -99,7 +98,7 @@ function loadConfig() {
|
|
|
99
98
|
timeoutMs: parseInt(env.API_TIMEOUT_MS, 10) || 3000000,
|
|
100
99
|
mcpToolTimeout: parseInt(env.MCP_TOOL_TIMEOUT, 10) || 30000,
|
|
101
100
|
mcpPlaywright: env.MCP_PLAYWRIGHT === 'true',
|
|
102
|
-
|
|
101
|
+
playwrightMode: env.MCP_PLAYWRIGHT_MODE || 'persistent',
|
|
103
102
|
disableNonessential: env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC || '',
|
|
104
103
|
effortLevel: env.CLAUDE_CODE_EFFORT_LEVEL || '',
|
|
105
104
|
smallFastModel: env.ANTHROPIC_SMALL_FAST_MODEL || '',
|
|
@@ -108,6 +107,7 @@ function loadConfig() {
|
|
|
108
107
|
defaultHaiku: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || '',
|
|
109
108
|
thinkingBudget: env.ANTHROPIC_THINKING_BUDGET || '',
|
|
110
109
|
stallTimeout: parseInt(env.SESSION_STALL_TIMEOUT, 10) || 1800,
|
|
110
|
+
editThreshold: parseInt(env.EDIT_THRESHOLD, 10) || 15,
|
|
111
111
|
raw: env,
|
|
112
112
|
};
|
|
113
113
|
|
|
@@ -191,6 +191,21 @@ function syncToGlobal() {
|
|
|
191
191
|
log('ok', `已同步配置到 ${settingsPath}`);
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
function updateEnvVar(key, value) {
|
|
195
|
+
const p = paths();
|
|
196
|
+
if (!fs.existsSync(p.envFile)) return false;
|
|
197
|
+
let content = fs.readFileSync(p.envFile, 'utf8');
|
|
198
|
+
const regex = new RegExp(`^${key}=.*$`, 'm');
|
|
199
|
+
if (regex.test(content)) {
|
|
200
|
+
content = content.replace(regex, `${key}=${value}`);
|
|
201
|
+
} else {
|
|
202
|
+
const suffix = content.endsWith('\n') ? '' : '\n';
|
|
203
|
+
content += `${suffix}${key}=${value}\n`;
|
|
204
|
+
}
|
|
205
|
+
fs.writeFileSync(p.envFile, content, 'utf8');
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
194
209
|
module.exports = {
|
|
195
210
|
COLOR,
|
|
196
211
|
log,
|
|
@@ -204,4 +219,5 @@ module.exports = {
|
|
|
204
219
|
buildEnvVars,
|
|
205
220
|
getAllowedTools,
|
|
206
221
|
syncToGlobal,
|
|
222
|
+
updateEnvVar,
|
|
207
223
|
};
|
package/src/hooks.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { inferPhaseStep } = require('./indicator');
|
|
4
4
|
const { log } = require('./config');
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const DEFAULT_EDIT_THRESHOLD = 30;
|
|
7
7
|
|
|
8
8
|
function logToolCall(logStream, input) {
|
|
9
9
|
if (!logStream) return;
|
|
@@ -31,6 +31,7 @@ function createSessionHooks(indicator, logStream, options = {}) {
|
|
|
31
31
|
enableStallDetection = false,
|
|
32
32
|
stallTimeoutMs = 1800000,
|
|
33
33
|
enableEditGuard = false,
|
|
34
|
+
editThreshold = DEFAULT_EDIT_THRESHOLD,
|
|
34
35
|
} = options;
|
|
35
36
|
|
|
36
37
|
const editCounts = {};
|
|
@@ -62,7 +63,7 @@ function createSessionHooks(indicator, logStream, options = {}) {
|
|
|
62
63
|
const target = input.tool_input?.file_path || input.tool_input?.path || '';
|
|
63
64
|
if (['Write', 'Edit', 'MultiEdit'].includes(input.tool_name) && target) {
|
|
64
65
|
editCounts[target] = (editCounts[target] || 0) + 1;
|
|
65
|
-
if (editCounts[target] >
|
|
66
|
+
if (editCounts[target] > editThreshold) {
|
|
66
67
|
return {
|
|
67
68
|
decision: 'block',
|
|
68
69
|
message: `已对 ${target} 编辑 ${editCounts[target]} 次,疑似死循环。请重新审视方案后再继续。`,
|
|
@@ -73,7 +74,16 @@ function createSessionHooks(indicator, logStream, options = {}) {
|
|
|
73
74
|
|
|
74
75
|
return {};
|
|
75
76
|
}]
|
|
76
|
-
}]
|
|
77
|
+
}],
|
|
78
|
+
PostToolUse: [{
|
|
79
|
+
matcher: '*',
|
|
80
|
+
hooks: [async () => {
|
|
81
|
+
indicator.updatePhase('thinking');
|
|
82
|
+
indicator.updateStep('');
|
|
83
|
+
indicator.toolTarget = '';
|
|
84
|
+
return {};
|
|
85
|
+
}]
|
|
86
|
+
}],
|
|
77
87
|
};
|
|
78
88
|
|
|
79
89
|
return {
|
package/src/indicator.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const { paths, COLOR } = require('./config');
|
|
3
|
+
const { COLOR } = require('./config');
|
|
5
4
|
|
|
6
|
-
const SPINNERS = ['⠋', '⠙', '
|
|
5
|
+
const SPINNERS = ['⠋', '⠙', '⠸', '⠴', '⠦', '⠇'];
|
|
7
6
|
|
|
8
7
|
class Indicator {
|
|
9
8
|
constructor() {
|
|
@@ -16,15 +15,14 @@ class Indicator {
|
|
|
16
15
|
this.lastToolTime = Date.now();
|
|
17
16
|
this.sessionNum = 0;
|
|
18
17
|
this.startTime = Date.now();
|
|
19
|
-
this.
|
|
20
|
-
this._lastRenderTime = 0;
|
|
18
|
+
this.stallTimeoutMin = 30;
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
start(sessionNum) {
|
|
21
|
+
start(sessionNum, stallTimeoutMin) {
|
|
24
22
|
this.sessionNum = sessionNum;
|
|
25
23
|
this.startTime = Date.now();
|
|
26
|
-
this.
|
|
27
|
-
this.timer = setInterval(() => this._render(),
|
|
24
|
+
if (stallTimeoutMin > 0) this.stallTimeoutMin = stallTimeoutMin;
|
|
25
|
+
this.timer = setInterval(() => this._render(), 1000);
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
stop() {
|
|
@@ -37,26 +35,16 @@ class Indicator {
|
|
|
37
35
|
|
|
38
36
|
updatePhase(phase) {
|
|
39
37
|
this.phase = phase;
|
|
40
|
-
this._writePhaseFile();
|
|
41
38
|
}
|
|
42
39
|
|
|
43
40
|
updateStep(step) {
|
|
44
41
|
this.step = step;
|
|
45
|
-
this._writeStepFile();
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
appendActivity(toolName, summary) {
|
|
49
45
|
this.lastActivity = `${toolName}: ${summary}`;
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
_writePhaseFile() {
|
|
53
|
-
try { fs.writeFileSync(paths().phaseFile, this.phase, 'utf8'); } catch { /* ignore */ }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
_writeStepFile() {
|
|
57
|
-
try { fs.writeFileSync(paths().stepFile, this.step, 'utf8'); } catch { /* ignore */ }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
48
|
getStatusLine() {
|
|
61
49
|
const now = new Date();
|
|
62
50
|
const hh = String(now.getHours()).padStart(2, '0');
|
|
@@ -78,32 +66,26 @@ class Indicator {
|
|
|
78
66
|
|
|
79
67
|
let line = `${spinner} [Session ${this.sessionNum}] ${clock} ${phaseLabel} ${mm}:${ss}`;
|
|
80
68
|
if (idleMin >= 2) {
|
|
81
|
-
line += ` | ${COLOR.red}${idleMin}
|
|
69
|
+
line += ` | ${COLOR.red}${idleMin}分无工具调用(等待模型响应, ${this.stallTimeoutMin}分钟超时自动中断)${COLOR.reset}`;
|
|
82
70
|
}
|
|
83
71
|
if (this.step) {
|
|
84
72
|
line += ` | ${this.step}`;
|
|
85
|
-
if (this.toolTarget)
|
|
73
|
+
if (this.toolTarget) {
|
|
74
|
+
const cols = process.stderr.columns || 80;
|
|
75
|
+
const usedWidth = line.replace(/\x1b\[[^m]*m/g, '').length;
|
|
76
|
+
const availWidth = Math.max(15, cols - usedWidth - 4);
|
|
77
|
+
const target = this.toolTarget.length > availWidth
|
|
78
|
+
? '…' + this.toolTarget.slice(-(availWidth - 1))
|
|
79
|
+
: this.toolTarget;
|
|
80
|
+
line += `: ${target}`;
|
|
81
|
+
}
|
|
86
82
|
}
|
|
87
83
|
return line;
|
|
88
84
|
}
|
|
89
85
|
|
|
90
86
|
_render() {
|
|
91
87
|
this.spinnerIndex++;
|
|
92
|
-
|
|
93
|
-
const now = Date.now();
|
|
94
|
-
const contentChanged = contentKey !== this._lastContentKey;
|
|
95
|
-
|
|
96
|
-
if (!contentChanged && now - this._lastRenderTime < 3000) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
this._lastContentKey = contentKey;
|
|
100
|
-
this._lastRenderTime = now;
|
|
101
|
-
|
|
102
|
-
const line = this.getStatusLine();
|
|
103
|
-
const maxWidth = process.stderr.columns || 80;
|
|
104
|
-
const truncated = line.length > maxWidth + 20 ? line.slice(0, maxWidth + 20) : line;
|
|
105
|
-
|
|
106
|
-
process.stderr.write(`\r\x1b[K${truncated}`);
|
|
88
|
+
process.stderr.write(`\r\x1b[K${this.getStatusLine()}`);
|
|
107
89
|
}
|
|
108
90
|
}
|
|
109
91
|
|
|
@@ -112,7 +94,7 @@ function extractFileTarget(toolInput) {
|
|
|
112
94
|
? (toolInput.file_path || toolInput.path || '')
|
|
113
95
|
: '';
|
|
114
96
|
if (!raw) return '';
|
|
115
|
-
return raw.split('/').slice(-2).join('/')
|
|
97
|
+
return raw.split('/').slice(-2).join('/');
|
|
116
98
|
}
|
|
117
99
|
|
|
118
100
|
function extractBashLabel(cmd) {
|
|
@@ -126,7 +108,7 @@ function extractBashLabel(cmd) {
|
|
|
126
108
|
function extractBashTarget(cmd) {
|
|
127
109
|
let clean = cmd.replace(/^(?:cd\s+\S+\s*&&\s*)+/g, '').trim();
|
|
128
110
|
clean = clean.split(/\s*(?:\|{1,2}|;|&&|2>&1|>\s*\/dev\/null)\s*/)[0].trim();
|
|
129
|
-
return clean
|
|
111
|
+
return clean;
|
|
130
112
|
}
|
|
131
113
|
|
|
132
114
|
function inferPhaseStep(indicator, toolName, toolInput) {
|
package/src/prompts.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
4
5
|
const { paths, loadConfig, getProjectRoot } = require('./config');
|
|
5
6
|
const { loadTasks, findNextTask, getStats } = require('./tasks');
|
|
6
7
|
|
|
@@ -97,12 +98,23 @@ function buildCodingPrompt(sessionNum, opts = {}) {
|
|
|
97
98
|
testEnvHint = `如需持久化测试凭证(API Key、测试账号密码等),写入 ${projectRoot}/.claude-coder/test.env(KEY=value 格式,每行一个)。后续 session 会自动感知。`;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
// Hint 6c: Playwright
|
|
101
|
+
// Hint 6c: Playwright mode awareness
|
|
101
102
|
let playwrightAuthHint = '';
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
if (config.mcpPlaywright) {
|
|
104
|
+
const mode = config.playwrightMode;
|
|
105
|
+
switch (mode) {
|
|
106
|
+
case 'persistent':
|
|
107
|
+
playwrightAuthHint = 'Playwright MCP 使用 persistent 模式(user-data-dir),浏览器登录状态持久保存在本地配置中,无需额外登录操作。';
|
|
108
|
+
break;
|
|
109
|
+
case 'isolated':
|
|
110
|
+
playwrightAuthHint = fs.existsSync(p.playwrightAuth)
|
|
111
|
+
? `Playwright MCP 使用 isolated 模式,已检测到登录状态文件(playwright-auth.json),每次会话自动加载 cookies 和 localStorage。`
|
|
112
|
+
: 'Playwright MCP 使用 isolated 模式,但未检测到登录状态文件。如目标页面需要登录,请先运行 claude-coder auth <URL>。';
|
|
113
|
+
break;
|
|
114
|
+
case 'extension':
|
|
115
|
+
playwrightAuthHint = 'Playwright MCP 使用 extension 模式,已连接用户真实浏览器,直接复用浏览器已有的登录态和扩展。注意:操作会影响用户正在使用的浏览器。';
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
// Hint 7: Session memory (read flat session_result.json)
|
|
@@ -110,9 +122,9 @@ function buildCodingPrompt(sessionNum, opts = {}) {
|
|
|
110
122
|
if (fs.existsSync(p.sessionResult)) {
|
|
111
123
|
try {
|
|
112
124
|
const sr = JSON.parse(fs.readFileSync(p.sessionResult, 'utf8'));
|
|
113
|
-
if (sr?.
|
|
114
|
-
memoryHint = `上次会话: ${sr.
|
|
115
|
-
(sr.notes ? `, 要点: ${sr.notes.slice(0,
|
|
125
|
+
if (sr?.session_result) {
|
|
126
|
+
memoryHint = `上次会话: ${sr.session_result}(${sr.status_before || '?'} → ${sr.status_after || '?'})` +
|
|
127
|
+
(sr.notes ? `, 要点: ${sr.notes.slice(0, 150)}` : '') + '。';
|
|
116
128
|
}
|
|
117
129
|
} catch { /* ignore */ }
|
|
118
130
|
}
|
|
@@ -200,7 +212,7 @@ function buildScanPrompt(projectType, requirement) {
|
|
|
200
212
|
'profile 质量要求(必须遵守,harness 会校验):',
|
|
201
213
|
'- services 数组必须包含所有可启动服务(command、port、health_check),不得为空',
|
|
202
214
|
'- existing_docs 必须列出所有实际存在的文档路径',
|
|
203
|
-
'-
|
|
215
|
+
'- 检查 .claude/CLAUDE.md 是否存在,若无则生成(WHAT/WHY/HOW 格式:技术栈、关键决策、开发命令、关键路径、编码规则),并加入 existing_docs',
|
|
204
216
|
'- scan_files_checked 必须列出所有实际扫描过的文件',
|
|
205
217
|
'',
|
|
206
218
|
'步骤 3:根据以下指导分解任务到 tasks.json(格式见 CLAUDE.md):',
|
|
@@ -265,6 +277,21 @@ function buildAddPrompt(instruction) {
|
|
|
265
277
|
}
|
|
266
278
|
} catch { /* ignore */ }
|
|
267
279
|
|
|
280
|
+
// --- Conditional: Playwright test rule hint ---
|
|
281
|
+
let testRuleHint = '';
|
|
282
|
+
const testRulePath = path.join(p.loopDir, 'test_rule.md');
|
|
283
|
+
const hasMcp = fs.existsSync(p.mcpConfig);
|
|
284
|
+
if (fs.existsSync(testRulePath) && hasMcp) {
|
|
285
|
+
testRuleHint = [
|
|
286
|
+
'【Playwright 测试规则】项目已配置 Playwright MCP(.mcp.json),' +
|
|
287
|
+
'`.claude-coder/test_rule.md` 中包含通用测试指导规则(Smart Snapshot、Token 预算控制、三步测试方法论、等待策略等)。',
|
|
288
|
+
'当任务涉及端到端测试时:',
|
|
289
|
+
' - 在 steps 中第一步加入「阅读 .claude-coder/test_rule.md 了解测试规范和成本控制」',
|
|
290
|
+
' - 测试步骤按 test_rule.md 中的 tasks.json 模板格式编写(含环境检查、优先级标注、预算控制)',
|
|
291
|
+
' - 设定合理的 test_tier(unit/smoke/regression/full_e2e)',
|
|
292
|
+
].join('\n');
|
|
293
|
+
}
|
|
294
|
+
|
|
268
295
|
return [
|
|
269
296
|
// --- Primacy zone: role + identity ---
|
|
270
297
|
'你是资深需求分析师,擅长将模糊需求分解为可执行的原子任务。',
|
|
@@ -287,12 +314,13 @@ function buildAddPrompt(instruction) {
|
|
|
287
314
|
'5. 分解任务:每个任务对应一个独立可测试的功能单元,description 简明(40字内),steps 具体可操作',
|
|
288
315
|
'6. 追加到 tasks.json,id 和 priority 从已有最大值递增,status: pending',
|
|
289
316
|
'7. git add -A && git commit -m "chore: add new tasks"',
|
|
290
|
-
'8. 写入 session_result.json(格式:{ "session_result": "success", "
|
|
317
|
+
'8. 写入 session_result.json(格式:{ "session_result": "success", "status_before": "N/A", "status_after": "N/A", "notes": "追加了 N 个任务:简述" })',
|
|
291
318
|
'',
|
|
292
319
|
|
|
293
320
|
// --- Quality constraints ---
|
|
294
321
|
taskGuide,
|
|
295
322
|
'',
|
|
323
|
+
testRuleHint,
|
|
296
324
|
'不修改已有任务,不实现代码。',
|
|
297
325
|
'',
|
|
298
326
|
|
package/src/runner.js
CHANGED
|
@@ -286,7 +286,7 @@ async function run(requirement, opts = {}) {
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
log('info', '开始 harness 校验 ...');
|
|
289
|
-
const validateResult = await validate(headBefore);
|
|
289
|
+
const validateResult = await validate(headBefore, taskId);
|
|
290
290
|
|
|
291
291
|
if (!validateResult.fatal) {
|
|
292
292
|
if (validateResult.hasWarnings) {
|
|
@@ -302,7 +302,7 @@ async function run(requirement, opts = {}) {
|
|
|
302
302
|
timestamp: new Date().toISOString(),
|
|
303
303
|
result: 'success',
|
|
304
304
|
cost: sessionResult.cost,
|
|
305
|
-
taskId
|
|
305
|
+
taskId,
|
|
306
306
|
statusAfter: validateResult.sessionData?.status_after || null,
|
|
307
307
|
notes: validateResult.sessionData?.notes || null,
|
|
308
308
|
});
|
|
@@ -375,8 +375,20 @@ async function add(instruction, opts = {}) {
|
|
|
375
375
|
process.exit(1);
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
deployTestRule(p);
|
|
379
|
+
|
|
378
380
|
await runAddSession(instruction, { projectRoot, ...opts });
|
|
379
381
|
printStats();
|
|
380
382
|
}
|
|
381
383
|
|
|
384
|
+
function deployTestRule(p) {
|
|
385
|
+
const dest = path.join(p.loopDir, 'test_rule.md');
|
|
386
|
+
if (fs.existsSync(dest)) return;
|
|
387
|
+
if (!fs.existsSync(p.testRuleTemplate)) return;
|
|
388
|
+
try {
|
|
389
|
+
fs.copyFileSync(p.testRuleTemplate, dest);
|
|
390
|
+
log('ok', '已部署测试指导规则 → .claude-coder/test_rule.md');
|
|
391
|
+
} catch { /* ignore */ }
|
|
392
|
+
}
|
|
393
|
+
|
|
382
394
|
module.exports = { run, add };
|
package/src/session.js
CHANGED
|
@@ -132,9 +132,11 @@ async function runCodingSession(sessionNum, opts = {}) {
|
|
|
132
132
|
enableStallDetection: true,
|
|
133
133
|
stallTimeoutMs,
|
|
134
134
|
enableEditGuard: true,
|
|
135
|
+
editThreshold: config.editThreshold,
|
|
135
136
|
});
|
|
136
137
|
|
|
137
|
-
|
|
138
|
+
const stallTimeoutMin = Math.floor(stallTimeoutMs / 60000);
|
|
139
|
+
indicator.start(sessionNum, stallTimeoutMin);
|
|
138
140
|
|
|
139
141
|
try {
|
|
140
142
|
const queryOpts = buildQueryOptions(config, opts);
|
|
@@ -202,7 +204,7 @@ async function runScanSession(requirement, opts = {}) {
|
|
|
202
204
|
stallTimeoutMs,
|
|
203
205
|
});
|
|
204
206
|
|
|
205
|
-
indicator.start(0);
|
|
207
|
+
indicator.start(0, Math.floor(stallTimeoutMs / 60000));
|
|
206
208
|
log('info', `正在调用 Claude Code 执行项目扫描(${projectType}项目)...`);
|
|
207
209
|
|
|
208
210
|
try {
|
|
@@ -263,7 +265,7 @@ async function runAddSession(instruction, opts = {}) {
|
|
|
263
265
|
stallTimeoutMs,
|
|
264
266
|
});
|
|
265
267
|
|
|
266
|
-
indicator.start(0);
|
|
268
|
+
indicator.start(0, Math.floor(stallTimeoutMs / 60000));
|
|
267
269
|
log('info', '正在追加任务...');
|
|
268
270
|
|
|
269
271
|
try {
|