claude-coder 1.8.4 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +214 -167
  2. package/bin/cli.js +155 -172
  3. package/package.json +55 -53
  4. package/recipes/_shared/roles/developer.md +11 -11
  5. package/recipes/_shared/roles/product.md +12 -12
  6. package/recipes/_shared/roles/tester.md +12 -12
  7. package/recipes/_shared/test/report-format.md +86 -86
  8. package/recipes/backend/base.md +27 -27
  9. package/recipes/backend/components/auth.md +18 -18
  10. package/recipes/backend/components/crud-api.md +18 -18
  11. package/recipes/backend/components/file-service.md +15 -15
  12. package/recipes/backend/manifest.json +20 -20
  13. package/recipes/backend/test/api-test.md +25 -25
  14. package/recipes/console/base.md +37 -37
  15. package/recipes/console/components/modal-form.md +20 -20
  16. package/recipes/console/components/pagination.md +17 -17
  17. package/recipes/console/components/search.md +17 -17
  18. package/recipes/console/components/table-list.md +18 -18
  19. package/recipes/console/components/tabs.md +14 -14
  20. package/recipes/console/components/tree.md +15 -15
  21. package/recipes/console/components/upload.md +15 -15
  22. package/recipes/console/manifest.json +24 -24
  23. package/recipes/console/test/crud-e2e.md +47 -47
  24. package/recipes/h5/base.md +26 -26
  25. package/recipes/h5/components/animation.md +11 -11
  26. package/recipes/h5/components/countdown.md +11 -11
  27. package/recipes/h5/components/share.md +11 -11
  28. package/recipes/h5/components/swiper.md +11 -11
  29. package/recipes/h5/manifest.json +21 -21
  30. package/recipes/h5/test/h5-e2e.md +20 -20
  31. package/src/commands/auth.js +362 -290
  32. package/src/commands/setup-modules/helpers.js +100 -99
  33. package/src/commands/setup-modules/index.js +25 -25
  34. package/src/commands/setup-modules/mcp.js +115 -95
  35. package/src/commands/setup-modules/provider.js +260 -260
  36. package/src/commands/setup-modules/safety.js +47 -61
  37. package/src/commands/setup-modules/simplify.js +52 -52
  38. package/src/commands/setup.js +172 -172
  39. package/src/common/assets.js +245 -236
  40. package/src/common/config.js +125 -125
  41. package/src/common/constants.js +55 -55
  42. package/src/common/indicator.js +260 -222
  43. package/src/common/interaction.js +170 -170
  44. package/src/common/logging.js +77 -77
  45. package/src/common/sdk.js +50 -50
  46. package/src/common/tasks.js +88 -88
  47. package/src/common/utils.js +213 -161
  48. package/src/core/coding.js +33 -55
  49. package/src/core/go.js +264 -310
  50. package/src/core/hooks.js +500 -533
  51. package/src/core/init.js +166 -171
  52. package/src/core/plan.js +188 -325
  53. package/src/core/prompts.js +247 -227
  54. package/src/core/repair.js +36 -46
  55. package/src/core/runner.js +458 -195
  56. package/src/core/scan.js +93 -89
  57. package/src/core/session.js +271 -57
  58. package/src/core/simplify.js +74 -53
  59. package/src/core/state.js +105 -0
  60. package/src/index.js +76 -0
  61. package/templates/bash-process.md +12 -12
  62. package/templates/codingSystem.md +65 -65
  63. package/templates/codingUser.md +17 -17
  64. package/templates/coreProtocol.md +29 -29
  65. package/templates/goSystem.md +130 -130
  66. package/templates/guidance.json +72 -53
  67. package/templates/planSystem.md +78 -78
  68. package/templates/planUser.md +8 -8
  69. package/templates/requirements.example.md +57 -57
  70. package/templates/scanSystem.md +120 -120
  71. package/templates/scanUser.md +10 -10
  72. package/templates/test_rule.md +194 -194
  73. package/templates/web-testing.md +17 -0
  74. package/types/index.d.ts +217 -0
  75. package/src/core/context.js +0 -117
  76. package/src/core/harness.js +0 -484
  77. package/src/core/query.js +0 -50
  78. package/templates/playwright.md +0 -17
package/src/core/hooks.js CHANGED
@@ -1,534 +1,501 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const { inferPhaseStep } = require('../common/indicator');
6
- const { log } = require('../common/config');
7
- const { EDIT_THRESHOLD, FILES } = require('../common/constants');
8
- const { createAskUserQuestionHook } = require('../common/interaction');
9
- const { assets } = require('../common/assets');
10
- const { localTimestamp } = require('../common/utils');
11
- // ─────────────────────────────────────────────────────────────
12
- // Constants
13
- // ─────────────────────────────────────────────────────────────
14
-
15
- const DEFAULT_EDIT_THRESHOLD = EDIT_THRESHOLD;
16
- const SESSION_RESULT_FILENAME = FILES.SESSION_RESULT;
17
-
18
- // Feature name constants
19
- const FEATURES = Object.freeze({
20
- GUIDANCE: 'guidance',
21
- EDIT_GUARD: 'editGuard',
22
- COMPLETION: 'completion',
23
- STALL: 'stall',
24
- INTERACTION: 'interaction',
25
- });
26
-
27
- // ─────────────────────────────────────────────────────────────
28
- // GuidanceInjector: JSON-based configurable guidance system
29
- // ─────────────────────────────────────────────────────────────
30
-
31
- class GuidanceInjector {
32
- constructor() {
33
- this.rules = [];
34
- this.cache = {};
35
- this.injectedRules = new Set(); // Track which rules have been injected once
36
- this.loaded = false;
37
- }
38
-
39
- /**
40
- * Load rules from user's guidance.json and pre-compile regex patterns.
41
- */
42
- load() {
43
- if (this.loaded) return;
44
-
45
- try {
46
- const content = assets.read('guidance');
47
- const config = JSON.parse(content);
48
- this.rules = config.rules || [];
49
- } catch {
50
- this.rules = [];
51
- }
52
-
53
- this._compiledMatchers = new Map();
54
- this._compiledConditions = new Map();
55
- for (const rule of this.rules) {
56
- try {
57
- this._compiledMatchers.set(rule.name, new RegExp(rule.matcher));
58
- } catch {
59
- this._compiledMatchers.set(rule.name, null);
60
- }
61
- if (rule.condition?.pattern !== undefined) {
62
- try {
63
- this._compiledConditions.set(rule.name, new RegExp(rule.condition.pattern, 'i'));
64
- } catch {
65
- this._compiledConditions.set(rule.name, null);
66
- }
67
- }
68
- }
69
-
70
- this.loaded = true;
71
- }
72
-
73
- /**
74
- * Get nested field value from object
75
- * @param {object} obj - Source object
76
- * @param {string} fieldPath - Dot-separated path (e.g., "tool_input.command")
77
- */
78
- getFieldValue(obj, fieldPath) {
79
- return fieldPath.split('.').reduce((o, k) => o?.[k], obj);
80
- }
81
-
82
- /**
83
- * Check if condition matches
84
- * Supports: { field, pattern } or { any: [...] }
85
- * @param {string} [ruleName] - Rule name for looking up pre-compiled regex
86
- */
87
- matchCondition(input, condition, ruleName) {
88
- if (!condition) return true;
89
-
90
- if (condition.field && condition.pattern !== undefined) {
91
- const value = this.getFieldValue(input, condition.field);
92
- const re = (ruleName && this._compiledConditions?.get(ruleName)) ||
93
- new RegExp(condition.pattern, 'i');
94
- return re.test(String(value || ''));
95
- }
96
-
97
- if (condition.any && Array.isArray(condition.any)) {
98
- return condition.any.some(c => this.matchCondition(input, c));
99
- }
100
-
101
- return true;
102
- }
103
-
104
- /**
105
- * Extract tool tip key from tool name
106
- * @param {string} toolName - Full tool name (e.g., "mcp__playwright__browser_snapshot")
107
- * @param {string} extractor - Regex pattern to extract key
108
- */
109
- extractToolTipKey(toolName, extractor) {
110
- if (!extractor) return null;
111
- const match = toolName.match(new RegExp(extractor));
112
- return match ? match[1] : null;
113
- }
114
-
115
- /**
116
- * Get rule file content
117
- * @param {object|string} file - File config or path string
118
- * @param {string} basePath - Base directory for relative paths
119
- */
120
- getFileContent(file, basePath) {
121
- if (!file) return null;
122
-
123
- const filePath = typeof file === 'string' ? file : file.path;
124
- const absolutePath = path.isAbsolute(filePath)
125
- ? filePath
126
- : path.join(basePath, filePath);
127
-
128
- try {
129
- return fs.readFileSync(absolutePath, 'utf8');
130
- } catch {
131
- return null;
132
- }
133
- }
134
-
135
- /**
136
- * Process a single rule and return guidance content
137
- */
138
- processRule(rule, input, basePath) {
139
- const matcherRe = this._compiledMatchers?.get(rule.name) ?? new RegExp(rule.matcher);
140
- if (!matcherRe.test(input.tool_name)) {
141
- return null;
142
- }
143
-
144
- if (!this.matchCondition(input, rule.condition, rule.name)) {
145
- return null;
146
- }
147
-
148
- const result = { guidance: '', tip: '' };
149
-
150
- // Process file content
151
- if (rule.file) {
152
- const fileConfig = typeof rule.file === 'object' ? rule.file : { path: rule.file };
153
- const injectOnce = fileConfig.injectOnce === true;
154
- const ruleKey = `${rule.name}_file`;
155
-
156
- // Skip if already injected and injectOnce is true
157
- if (injectOnce && this.injectedRules.has(ruleKey)) {
158
- // Don't inject file content again
159
- } else {
160
- if (injectOnce) this.injectedRules.add(ruleKey);
161
-
162
- // Get cached content or read file
163
- const cacheKey = `${rule.name}_content`;
164
- if (!this.cache[cacheKey]) {
165
- this.cache[cacheKey] = this.getFileContent(fileConfig.path, basePath);
166
- }
167
- result.guidance = this.cache[cacheKey] || '';
168
- }
169
- }
170
-
171
- // Process tool tips
172
- if (rule.toolTips && rule.toolTips.items) {
173
- const tipKey = this.extractToolTipKey(input.tool_name, rule.toolTips.extractor);
174
- if (tipKey && rule.toolTips.items[tipKey]) {
175
- const tipInjectOnce = rule.toolTips.injectOnce !== false; // Default true
176
- const tipRuleKey = `${rule.name}_tip_${tipKey}`;
177
-
178
- if (!tipInjectOnce || !this.injectedRules.has(tipRuleKey)) {
179
- if (tipInjectOnce) this.injectedRules.add(tipRuleKey);
180
- result.tip = rule.toolTips.items[tipKey];
181
- }
182
- }
183
- }
184
-
185
- return result;
186
- }
187
-
188
- /**
189
- * Reset per-session state for clean session boundaries.
190
- * Also clears loaded flag so guidance.json is re-read on next hook call.
191
- */
192
- reset() {
193
- this.injectedRules.clear();
194
- this.cache = {};
195
- this.loaded = false;
196
- }
197
-
198
- /**
199
- * Create hook function for PreToolUse
200
- */
201
- createHook() {
202
- const basePath = assets.dir('loop');
203
-
204
- return async (input, _toolUseID, _context) => {
205
- this.load();
206
-
207
- if (this.rules.length === 0) return {};
208
-
209
- const guidanceParts = [];
210
- const tipParts = [];
211
-
212
- for (const rule of this.rules) {
213
- const result = this.processRule(rule, input, basePath);
214
- if (result) {
215
- if (result.guidance) guidanceParts.push(result.guidance);
216
- if (result.tip) tipParts.push(result.tip);
217
- }
218
- }
219
-
220
- const allParts = [...guidanceParts, ...tipParts];
221
- if (allParts.length === 0) return {};
222
-
223
- return {
224
- hookSpecificOutput: {
225
- hookEventName: 'PreToolUse',
226
- additionalContext: allParts.join('\n\n'),
227
- }
228
- };
229
- };
230
- }
231
- }
232
-
233
- // Shared instance (reset per session via createGuidanceModule)
234
- const guidanceInjector = new GuidanceInjector();
235
-
236
- // ─────────────────────────────────────────────────────────────
237
- // Utility Functions
238
- // ─────────────────────────────────────────────────────────────
239
-
240
-
241
- function logToolCall(logStream, input) {
242
- if (!logStream) return;
243
- const target = input.tool_input?.file_path || input.tool_input?.path || '';
244
- const cmd = input.tool_input?.command || '';
245
- const pattern = input.tool_input?.pattern || '';
246
- const detail = target || cmd.slice(0, 200) || (pattern ? `pattern: ${pattern}` : '');
247
- if (detail) {
248
- logStream.write(`[${localTimestamp()}] ${input.tool_name}: ${detail}\n`);
249
- }
250
- }
251
-
252
- function isSessionResultWrite(toolName, toolInput) {
253
- if (toolName === 'Write') {
254
- const target = toolInput?.file_path || toolInput?.path || '';
255
- return target.endsWith(SESSION_RESULT_FILENAME);
256
- }
257
- if (toolName === 'Bash' || toolName === 'Shell') {
258
- const cmd = toolInput?.command || '';
259
- if (!cmd.includes(SESSION_RESULT_FILENAME)) return false;
260
- return />\s*[^\s]*session_result/.test(cmd);
261
- }
262
- return false;
263
- }
264
-
265
- // ─────────────────────────────────────────────────────────────
266
- // Module Factories
267
- // ─────────────────────────────────────────────────────────────
268
-
269
- /**
270
- * Create guidance injection module.
271
- * Resets the shared injector's per-session state to prevent cross-session leaks.
272
- */
273
- function createGuidanceModule() {
274
- guidanceInjector.reset();
275
- return {
276
- hook: guidanceInjector.createHook()
277
- };
278
- }
279
-
280
- /**
281
- * Create edit guard module.
282
- * Uses a sliding time window: edits older than cooldownMs are decayed,
283
- * allowing the model to resume editing after a "thinking" break.
284
- */
285
- function createEditGuardModule(options) {
286
- const editTimestamps = {};
287
- const threshold = options.editThreshold || DEFAULT_EDIT_THRESHOLD;
288
- const cooldownMs = options.editCooldownMs || 60000;
289
-
290
- return {
291
- hook: async (input, _toolUseID, _context) => {
292
- if (!['Write', 'Edit', 'MultiEdit'].includes(input.tool_name)) return {};
293
- const target = input.tool_input?.file_path || input.tool_input?.path || '';
294
- if (!target) return {};
295
-
296
- const now = Date.now();
297
- if (!editTimestamps[target]) editTimestamps[target] = [];
298
-
299
- editTimestamps[target] = editTimestamps[target].filter(t => now - t < cooldownMs);
300
- editTimestamps[target].push(now);
301
-
302
- if (editTimestamps[target].length > threshold) {
303
- return {
304
- hookSpecificOutput: {
305
- hookEventName: 'PreToolUse',
306
- permissionDecision: 'deny',
307
- permissionDecisionReason:
308
- `${cooldownMs / 1000}s 内对 ${target} 编辑 ${editTimestamps[target].length} 次(上限 ${threshold}),疑似死循环。请重新审视方案后再继续。`
309
- }
310
- };
311
- }
312
- return {};
313
- }
314
- };
315
- }
316
-
317
- /**
318
- * Create stall detection module (includes completion timeout handling)
319
- */
320
- function createStallModule(indicator, logStream, options) {
321
- let stallDetected = false;
322
- let stallChecker = null;
323
- const timeoutMs = options.stallTimeoutMs || 1200000;
324
- const completionTimeoutMs = options.completionTimeoutMs || 300000;
325
- const abortController = options.abortController;
326
- let completionDetectedAt = 0;
327
-
328
- const checkStall = () => {
329
- const now = Date.now();
330
- const idleMs = now - indicator.lastActivityTime;
331
-
332
- // Priority: completion timeout
333
- if (completionDetectedAt > 0) {
334
- const sinceCompletion = now - completionDetectedAt;
335
- if (sinceCompletion > completionTimeoutMs && !stallDetected) {
336
- stallDetected = true;
337
- const shortMin = Math.ceil(completionTimeoutMs / 60000);
338
- const actualMin = Math.floor(sinceCompletion / 60000);
339
- log('warn', `\nsession_result 已写入 ${actualMin} 分钟,超过 ${shortMin} 分钟上限,自动中断`);
340
- if (logStream) {
341
- logStream.write(`\n[${localTimestamp()}] STALL: session_result 写入后 ${actualMin} 分钟(上限 ${shortMin} 分钟),自动中断\n`);
342
- }
343
- if (abortController) {
344
- abortController.abort();
345
- log('warn', '\n已发送中断信号');
346
- }
347
- }
348
- return;
349
- }
350
-
351
- // Normal stall detection
352
- if (idleMs > timeoutMs && !stallDetected) {
353
- stallDetected = true;
354
- const idleMin = Math.floor(idleMs / 60000);
355
- log('warn', `\n无响应超过 ${idleMin} 分钟,自动中断 session`);
356
- if (logStream) {
357
- logStream.write(`\n[${localTimestamp()}] STALL: 无响应 ${idleMin} 分钟,自动中断\n`);
358
- }
359
- if (abortController) {
360
- abortController.abort();
361
- log('warn', '\n已发送中断信号');
362
- }
363
- }
364
- };
365
-
366
- stallChecker = setInterval(checkStall, 30000);
367
-
368
- return {
369
- setCompletionDetected: () => { completionDetectedAt = Date.now(); },
370
- onCompletionDetected: () => {
371
- completionDetectedAt = Date.now();
372
- const shortMin = Math.ceil(completionTimeoutMs / 60000);
373
- indicator.setCompletionDetected(shortMin);
374
- log('info', '');
375
- log('info', `检测到 session_result 写入,${shortMin} 分钟内模型未终止将自动中断`);
376
- if (logStream) {
377
- logStream.write(`\n[${localTimestamp()}] COMPLETION_DETECTED: session_result.json written, ${shortMin}min grace period\n`);
378
- }
379
- },
380
- cleanup: () => { if (stallChecker) clearInterval(stallChecker); },
381
- isStalled: () => stallDetected
382
- };
383
- }
384
-
385
- /**
386
- * Create completion detection module (PostToolUse hook)
387
- * endTool() resets toolRunning and refreshes lastActivityTime (countdown reset)
388
- */
389
- function createCompletionModule(indicator, stallModule) {
390
- return {
391
- hook: async (input, _toolUseID, _context) => {
392
- indicator.endTool();
393
- indicator.updatePhase('thinking');
394
- indicator.updateStep('');
395
- indicator.toolTarget = '';
396
-
397
- if (isSessionResultWrite(input.tool_name, input.tool_input)) {
398
- stallModule.onCompletionDetected();
399
- }
400
- return {};
401
- }
402
- };
403
- }
404
-
405
- /**
406
- * Create PostToolUseFailure hook to ensure endTool on tool errors
407
- */
408
- function createFailureHook(indicator) {
409
- return async (_input, _toolUseID, _context) => {
410
- indicator.endTool();
411
- return {};
412
- };
413
- }
414
-
415
- /**
416
- * Create logging hook
417
- */
418
- function createLoggingHook(indicator, logStream) {
419
- return async (input, _toolUseID, _context) => {
420
- inferPhaseStep(indicator, input.tool_name, input.tool_input);
421
- logToolCall(logStream, input);
422
- return {};
423
- };
424
- }
425
-
426
- // ─────────────────────────────────────────────────────────────
427
- // Hook Factory: createHooks
428
- // ─────────────────────────────────────────────────────────────
429
-
430
- const FEATURE_MAP = {
431
- coding: [FEATURES.GUIDANCE, FEATURES.EDIT_GUARD, FEATURES.COMPLETION, FEATURES.STALL],
432
- plan: [FEATURES.STALL],
433
- plan_interactive: [FEATURES.STALL, FEATURES.INTERACTION],
434
- scan: [FEATURES.STALL],
435
- add: [FEATURES.STALL],
436
- simplify: [FEATURES.STALL, FEATURES.INTERACTION],
437
- go: [FEATURES.STALL, FEATURES.INTERACTION],
438
- custom: null
439
- };
440
-
441
- /**
442
- * Create hooks based on session type
443
- */
444
- function createHooks(type, indicator, logStream, options = {}) {
445
- const features = type === 'custom'
446
- ? (options.features || [FEATURES.STALL])
447
- : (FEATURE_MAP[type] || [FEATURES.STALL]);
448
-
449
- const modules = {};
450
-
451
- if (features.includes(FEATURES.STALL)) {
452
- modules.stall = createStallModule(indicator, logStream, options);
453
- }
454
-
455
- if (features.includes(FEATURES.EDIT_GUARD)) {
456
- modules.editGuard = createEditGuardModule(options);
457
- }
458
-
459
- if (features.includes(FEATURES.COMPLETION) && modules.stall) {
460
- modules.completion = createCompletionModule(indicator, modules.stall);
461
- }
462
-
463
- if (features.includes(FEATURES.GUIDANCE)) {
464
- modules.guidance = createGuidanceModule();
465
- }
466
-
467
- if (features.includes(FEATURES.INTERACTION)) {
468
- modules.interaction = { hook: createAskUserQuestionHook(indicator) };
469
- }
470
-
471
- // Assemble PreToolUse hooks
472
- const preToolUseHooks = [];
473
- preToolUseHooks.push(createLoggingHook(indicator, logStream));
474
-
475
- if (modules.editGuard) {
476
- preToolUseHooks.push(modules.editGuard.hook);
477
- }
478
-
479
- if (modules.guidance) {
480
- preToolUseHooks.push(modules.guidance.hook);
481
- }
482
-
483
- if (modules.interaction) {
484
- preToolUseHooks.push(modules.interaction.hook);
485
- }
486
-
487
- // Assemble PostToolUse hooks (always include endTool for countdown reset)
488
- const postToolUseHooks = [];
489
- if (modules.completion) {
490
- postToolUseHooks.push(modules.completion.hook);
491
- } else {
492
- postToolUseHooks.push(createFailureHook(indicator));
493
- }
494
-
495
- // PostToolUseFailure hook: ensure endTool even on tool errors
496
- const failureHook = createFailureHook(indicator);
497
-
498
- // Build hooks object
499
- const hooks = {};
500
- if (preToolUseHooks.length > 0) {
501
- hooks.PreToolUse = [{ matcher: '*', hooks: preToolUseHooks }];
502
- }
503
- if (postToolUseHooks.length > 0) {
504
- hooks.PostToolUse = [{ matcher: '*', hooks: postToolUseHooks }];
505
- }
506
- hooks.PostToolUseFailure = [{ matcher: '*', hooks: [failureHook] }];
507
-
508
- // Cleanup functions
509
- const cleanupFns = [];
510
- if (modules.stall) {
511
- cleanupFns.push(modules.stall.cleanup);
512
- }
513
-
514
- return {
515
- hooks,
516
- cleanup: () => cleanupFns.forEach(fn => fn()),
517
- isStalled: () => modules.stall?.isStalled() || false
518
- };
519
- }
520
-
521
- // ─────────────────────────────────────────────────────────────
522
- // Exports
523
- // ─────────────────────────────────────────────────────────────
524
-
525
- module.exports = {
526
- createHooks,
527
- GuidanceInjector,
528
- createGuidanceModule,
529
- createEditGuardModule,
530
- createCompletionModule,
531
- createStallModule,
532
- FEATURES,
533
- isSessionResultWrite,
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { inferPhaseStep } = require('../common/indicator');
6
+ const { log } = require('../common/config');
7
+ const { EDIT_THRESHOLD } = require('../common/constants');
8
+ const { createAskUserQuestionHook } = require('../common/interaction');
9
+ const { assets } = require('../common/assets');
10
+ const { localTimestamp } = require('../common/utils');
11
+ // ─────────────────────────────────────────────────────────────
12
+ // Constants
13
+ // ─────────────────────────────────────────────────────────────
14
+
15
+ const DEFAULT_EDIT_THRESHOLD = EDIT_THRESHOLD;
16
+
17
+ // Feature name constants
18
+ const FEATURES = Object.freeze({
19
+ GUIDANCE: 'guidance',
20
+ EDIT_GUARD: 'editGuard',
21
+ STOP: 'stop',
22
+ STALL: 'stall',
23
+ INTERACTION: 'interaction',
24
+ });
25
+
26
+ // ─────────────────────────────────────────────────────────────
27
+ // GuidanceInjector: JSON-based configurable guidance system
28
+ // ─────────────────────────────────────────────────────────────
29
+
30
+ class GuidanceInjector {
31
+ constructor() {
32
+ this.rules = [];
33
+ this.cache = {};
34
+ this.injectedRules = new Set(); // Track which rules have been injected once
35
+ this.loaded = false;
36
+ }
37
+
38
+ /**
39
+ * Load rules from user's guidance.json and pre-compile regex patterns.
40
+ */
41
+ load() {
42
+ if (this.loaded) return;
43
+
44
+ try {
45
+ const content = assets.read('guidance');
46
+ const config = JSON.parse(content);
47
+ this.rules = config.rules || [];
48
+ } catch {
49
+ this.rules = [];
50
+ }
51
+
52
+ this._compiledMatchers = new Map();
53
+ this._compiledConditions = new Map();
54
+ for (const rule of this.rules) {
55
+ try {
56
+ this._compiledMatchers.set(rule.name, new RegExp(rule.matcher));
57
+ } catch {
58
+ this._compiledMatchers.set(rule.name, null);
59
+ }
60
+ if (rule.condition?.pattern !== undefined) {
61
+ try {
62
+ this._compiledConditions.set(rule.name, new RegExp(rule.condition.pattern, 'i'));
63
+ } catch {
64
+ this._compiledConditions.set(rule.name, null);
65
+ }
66
+ }
67
+ }
68
+
69
+ this.loaded = true;
70
+ }
71
+
72
+ /**
73
+ * Get nested field value from object
74
+ * @param {object} obj - Source object
75
+ * @param {string} fieldPath - Dot-separated path (e.g., "tool_input.command")
76
+ */
77
+ getFieldValue(obj, fieldPath) {
78
+ return fieldPath.split('.').reduce((o, k) => o?.[k], obj);
79
+ }
80
+
81
+ /**
82
+ * Check if condition matches
83
+ * Supports: { field, pattern } or { any: [...] }
84
+ * @param {string} [ruleName] - Rule name for looking up pre-compiled regex
85
+ */
86
+ matchCondition(input, condition, ruleName) {
87
+ if (!condition) return true;
88
+
89
+ if (condition.field && condition.pattern !== undefined) {
90
+ const value = this.getFieldValue(input, condition.field);
91
+ const re = (ruleName && this._compiledConditions?.get(ruleName)) ||
92
+ new RegExp(condition.pattern, 'i');
93
+ return re.test(String(value || ''));
94
+ }
95
+
96
+ if (condition.any && Array.isArray(condition.any)) {
97
+ return condition.any.some(c => this.matchCondition(input, c));
98
+ }
99
+
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * Extract tool tip key from tool name
105
+ * @param {string} toolName - Full tool name (e.g., "mcp__playwright__browser_snapshot")
106
+ * @param {string} extractor - Regex pattern to extract key
107
+ */
108
+ extractToolTipKey(toolName, extractor) {
109
+ if (!extractor) return null;
110
+ const match = toolName.match(new RegExp(extractor));
111
+ return match ? match[1] : null;
112
+ }
113
+
114
+ /**
115
+ * Get rule file content
116
+ * @param {object|string} file - File config or path string
117
+ * @param {string} basePath - Base directory for relative paths
118
+ */
119
+ getFileContent(file, basePath) {
120
+ if (!file) return null;
121
+
122
+ const filePath = typeof file === 'string' ? file : file.path;
123
+ const absolutePath = path.isAbsolute(filePath)
124
+ ? filePath
125
+ : path.join(basePath, filePath);
126
+
127
+ try {
128
+ return fs.readFileSync(absolutePath, 'utf8');
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Process a single rule and return guidance content
136
+ */
137
+ processRule(rule, input, basePath) {
138
+ const matcherRe = this._compiledMatchers?.get(rule.name) ?? new RegExp(rule.matcher);
139
+ if (!matcherRe.test(input.tool_name)) {
140
+ return null;
141
+ }
142
+
143
+ if (!this.matchCondition(input, rule.condition, rule.name)) {
144
+ return null;
145
+ }
146
+
147
+ const result = { guidance: '', tip: '' };
148
+
149
+ // Process file content
150
+ if (rule.file) {
151
+ const fileConfig = typeof rule.file === 'object' ? rule.file : { path: rule.file };
152
+ const injectOnce = fileConfig.injectOnce === true;
153
+ const ruleKey = `${rule.name}_file`;
154
+
155
+ // Skip if already injected and injectOnce is true
156
+ if (injectOnce && this.injectedRules.has(ruleKey)) {
157
+ // Don't inject file content again
158
+ } else {
159
+ if (injectOnce) this.injectedRules.add(ruleKey);
160
+
161
+ // Get cached content or read file
162
+ const cacheKey = `${rule.name}_content`;
163
+ if (!this.cache[cacheKey]) {
164
+ this.cache[cacheKey] = this.getFileContent(fileConfig.path, basePath);
165
+ }
166
+ result.guidance = this.cache[cacheKey] || '';
167
+ }
168
+ }
169
+
170
+ // Process tool tips
171
+ if (rule.toolTips && rule.toolTips.items) {
172
+ const tipKey = this.extractToolTipKey(input.tool_name, rule.toolTips.extractor);
173
+ if (tipKey && rule.toolTips.items[tipKey]) {
174
+ const tipInjectOnce = rule.toolTips.injectOnce !== false; // Default true
175
+ const tipRuleKey = `${rule.name}_tip_${tipKey}`;
176
+
177
+ if (!tipInjectOnce || !this.injectedRules.has(tipRuleKey)) {
178
+ if (tipInjectOnce) this.injectedRules.add(tipRuleKey);
179
+ result.tip = rule.toolTips.items[tipKey];
180
+ }
181
+ }
182
+ }
183
+
184
+ return result;
185
+ }
186
+
187
+ /**
188
+ * Reset per-session state for clean session boundaries.
189
+ * Also clears loaded flag so guidance.json is re-read on next hook call.
190
+ */
191
+ reset() {
192
+ this.injectedRules.clear();
193
+ this.cache = {};
194
+ this.loaded = false;
195
+ }
196
+
197
+ /**
198
+ * Create hook function for PreToolUse
199
+ */
200
+ createHook() {
201
+ const basePath = assets.dir('loop');
202
+
203
+ return async (input, _toolUseID, _context) => {
204
+ this.load();
205
+
206
+ if (this.rules.length === 0) return {};
207
+
208
+ const guidanceParts = [];
209
+ const tipParts = [];
210
+
211
+ for (const rule of this.rules) {
212
+ const result = this.processRule(rule, input, basePath);
213
+ if (result) {
214
+ if (result.guidance) guidanceParts.push(result.guidance);
215
+ if (result.tip) tipParts.push(result.tip);
216
+ }
217
+ }
218
+
219
+ const allParts = [...guidanceParts, ...tipParts];
220
+ if (allParts.length === 0) return {};
221
+
222
+ return {
223
+ hookSpecificOutput: {
224
+ hookEventName: 'PreToolUse',
225
+ additionalContext: allParts.join('\n\n'),
226
+ }
227
+ };
228
+ };
229
+ }
230
+ }
231
+
232
+ // Shared instance (reset per session via createGuidanceModule)
233
+ const guidanceInjector = new GuidanceInjector();
234
+
235
+ // ─────────────────────────────────────────────────────────────
236
+ // Utility Functions
237
+ // ─────────────────────────────────────────────────────────────
238
+
239
+
240
+ function logToolCall(logStream, input) {
241
+ if (!logStream) return;
242
+ const target = input.tool_input?.file_path || input.tool_input?.path || '';
243
+ const cmd = input.tool_input?.command || '';
244
+ const pattern = input.tool_input?.pattern || '';
245
+ const detail = target || cmd.slice(0, 200) || (pattern ? `pattern: ${pattern}` : '');
246
+ if (detail) {
247
+ logStream.write(`[${localTimestamp()}] ${input.tool_name}: ${detail}\n`);
248
+ }
249
+ }
250
+
251
+ // ─────────────────────────────────────────────────────────────
252
+ // Module Factories
253
+ // ─────────────────────────────────────────────────────────────
254
+
255
+ /**
256
+ * Create guidance injection module.
257
+ * Resets the shared injector's per-session state to prevent cross-session leaks.
258
+ */
259
+ function createGuidanceModule() {
260
+ guidanceInjector.reset();
261
+ return {
262
+ hook: guidanceInjector.createHook()
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Create edit guard module.
268
+ * Uses a sliding time window: edits older than cooldownMs are decayed,
269
+ * allowing the model to resume editing after a "thinking" break.
270
+ */
271
+ function createEditGuardModule(options) {
272
+ const editTimestamps = {};
273
+ const threshold = options.editThreshold || DEFAULT_EDIT_THRESHOLD;
274
+ const cooldownMs = options.editCooldownMs || 60000;
275
+
276
+ return {
277
+ hook: async (input, _toolUseID, _context) => {
278
+ if (!['Write', 'Edit', 'MultiEdit'].includes(input.tool_name)) return {};
279
+ const target = input.tool_input?.file_path || input.tool_input?.path || '';
280
+ if (!target) return {};
281
+
282
+ const now = Date.now();
283
+ if (!editTimestamps[target]) editTimestamps[target] = [];
284
+
285
+ editTimestamps[target] = editTimestamps[target].filter(t => now - t < cooldownMs);
286
+ editTimestamps[target].push(now);
287
+
288
+ if (editTimestamps[target].length > threshold) {
289
+ return {
290
+ hookSpecificOutput: {
291
+ hookEventName: 'PreToolUse',
292
+ permissionDecision: 'deny',
293
+ permissionDecisionReason:
294
+ `${cooldownMs / 1000}s 内对 ${target} 编辑 ${editTimestamps[target].length} 次(上限 ${threshold}),疑似死循环。请重新审视方案后再继续。`
295
+ }
296
+ };
297
+ }
298
+ return {};
299
+ }
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Create stall detection module (idle timeout only)
305
+ */
306
+ function createStallModule(indicator, logStream, options) {
307
+ let stallDetected = false;
308
+ let stallChecker = null;
309
+ const timeoutMs = options.stallTimeoutMs || 1200000;
310
+ const abortController = options.abortController;
311
+
312
+ const checkStall = () => {
313
+ const now = Date.now();
314
+ const idleMs = now - indicator.lastActivityTime;
315
+
316
+ if (idleMs > timeoutMs && !stallDetected) {
317
+ stallDetected = true;
318
+ const idleMin = Math.floor(idleMs / 60000);
319
+ log('warn', `\n无响应超过 ${idleMin} 分钟,自动中断 session`);
320
+ if (logStream) {
321
+ logStream.write(`\n[${localTimestamp()}] STALL: 无响应 ${idleMin} 分钟,自动中断\n`);
322
+ }
323
+ if (abortController) {
324
+ abortController.abort();
325
+ log('warn', '\n已发送中断信号');
326
+ }
327
+ }
328
+ };
329
+
330
+ stallChecker = setInterval(checkStall, 30000);
331
+
332
+ return {
333
+ cleanup: () => { if (stallChecker) clearInterval(stallChecker); },
334
+ isStalled: () => stallDetected
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Create Stop hook per-turn activity logger.
340
+ * Stop fires on EVERY model response turn (not just session end).
341
+ * For session completion detection, use the result message (SDKResultMessage.subtype).
342
+ */
343
+ function createStopHook(logStream) {
344
+ return async (_input) => {
345
+ if (logStream?.writable) {
346
+ logStream.write(`[${localTimestamp()}] STOP: turn completed\n`);
347
+ }
348
+ return {};
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Create PostToolUse hook — resets tool running state and activity timer.
354
+ * Unified for all session types (replaces the former createCompletionModule).
355
+ */
356
+ function createEndToolHook(indicator) {
357
+ return async (_input, _toolUseID, _context) => {
358
+ indicator.endTool();
359
+ indicator.updatePhase('thinking');
360
+ return {};
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Create PostToolUseFailure hook to ensure endTool on tool errors
366
+ */
367
+ function createFailureHook(indicator) {
368
+ return async (_input, _toolUseID, _context) => {
369
+ indicator.endTool();
370
+ return {};
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Create logging hook
376
+ */
377
+ function createLoggingHook(indicator, logStream) {
378
+ return async (input, _toolUseID, _context) => {
379
+ inferPhaseStep(indicator, input.tool_name, input.tool_input);
380
+ logToolCall(logStream, input);
381
+ return {};
382
+ };
383
+ }
384
+
385
+ // ─────────────────────────────────────────────────────────────
386
+ // Hook Factory: createHooks
387
+ // ─────────────────────────────────────────────────────────────
388
+
389
+ const FEATURE_MAP = {
390
+ coding: [FEATURES.GUIDANCE, FEATURES.EDIT_GUARD, FEATURES.STOP, FEATURES.STALL],
391
+ plan: [FEATURES.STOP, FEATURES.STALL],
392
+ plan_interactive: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
393
+ scan: [FEATURES.STOP, FEATURES.STALL],
394
+ add: [FEATURES.STOP, FEATURES.STALL],
395
+ simplify: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
396
+ go: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
397
+ custom: null
398
+ };
399
+
400
+ /**
401
+ * Create hooks based on session type
402
+ */
403
+ function createHooks(type, indicator, logStream, options = {}) {
404
+ const features = type === 'custom'
405
+ ? (options.features || [FEATURES.STALL])
406
+ : (FEATURE_MAP[type] || [FEATURES.STALL]);
407
+
408
+ const modules = {};
409
+
410
+ if (features.includes(FEATURES.STALL)) {
411
+ modules.stall = createStallModule(indicator, logStream, options);
412
+ }
413
+
414
+ if (features.includes(FEATURES.STOP)) {
415
+ modules.stopHook = createStopHook(logStream);
416
+ }
417
+
418
+ if (features.includes(FEATURES.EDIT_GUARD)) {
419
+ modules.editGuard = createEditGuardModule(options);
420
+ }
421
+
422
+ if (features.includes(FEATURES.GUIDANCE)) {
423
+ modules.guidance = createGuidanceModule();
424
+ }
425
+
426
+ if (features.includes(FEATURES.INTERACTION)) {
427
+ modules.interaction = { hook: createAskUserQuestionHook(indicator) };
428
+ }
429
+
430
+ // Assemble PreToolUse hooks
431
+ const preToolUseHooks = [];
432
+ preToolUseHooks.push(createLoggingHook(indicator, logStream));
433
+
434
+ if (modules.editGuard) {
435
+ preToolUseHooks.push(modules.editGuard.hook);
436
+ }
437
+
438
+ if (modules.guidance) {
439
+ preToolUseHooks.push(modules.guidance.hook);
440
+ }
441
+
442
+ if (modules.interaction) {
443
+ preToolUseHooks.push(modules.interaction.hook);
444
+ }
445
+
446
+ // PostToolUse: unified endTool for all session types
447
+ const endToolHook = createEndToolHook(indicator);
448
+
449
+ // PostToolUseFailure: ensure endTool even on tool errors
450
+ const failureHook = createFailureHook(indicator);
451
+
452
+ // Build hooks object
453
+ const hooks = {};
454
+ if (preToolUseHooks.length > 0) {
455
+ hooks.PreToolUse = [{ matcher: '*', hooks: preToolUseHooks }];
456
+ }
457
+ hooks.PostToolUse = [{ matcher: '*', hooks: [endToolHook] }];
458
+ hooks.PostToolUseFailure = [{ matcher: '*', hooks: [failureHook] }];
459
+
460
+ // Stop hook: per-turn activity logger
461
+ if (modules.stopHook) {
462
+ hooks.Stop = [{ hooks: [modules.stopHook] }];
463
+ }
464
+
465
+ // SessionStart hook: log query lifecycle
466
+ const sessionStartHook = async (input) => {
467
+ indicator.updateActivity();
468
+ if (logStream?.writable) {
469
+ logStream.write(`[${localTimestamp()}] SESSION_START: source=${input.source || 'unknown'}\n`);
470
+ }
471
+ return {};
472
+ };
473
+ hooks.SessionStart = [{ hooks: [sessionStartHook] }];
474
+
475
+ // Cleanup functions
476
+ const cleanupFns = [];
477
+ if (modules.stall) {
478
+ cleanupFns.push(modules.stall.cleanup);
479
+ }
480
+
481
+ return {
482
+ hooks,
483
+ cleanup: () => cleanupFns.forEach(fn => fn()),
484
+ isStalled: () => modules.stall?.isStalled() || false,
485
+ };
486
+ }
487
+
488
+ // ─────────────────────────────────────────────────────────────
489
+ // Exports
490
+ // ─────────────────────────────────────────────────────────────
491
+
492
+ module.exports = {
493
+ createHooks,
494
+ GuidanceInjector,
495
+ createGuidanceModule,
496
+ createEditGuardModule,
497
+ createStopHook,
498
+ createEndToolHook,
499
+ createStallModule,
500
+ FEATURES,
534
501
  };