claude-coder 1.10.0 → 1.10.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 (81) hide show
  1. package/README.md +239 -236
  2. package/bin/cli.js +170 -170
  3. package/package.json +55 -55
  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 +420 -420
  32. package/src/commands/setup-modules/helpers.js +100 -100
  33. package/src/commands/setup-modules/index.js +25 -25
  34. package/src/commands/setup-modules/mcp.js +115 -115
  35. package/src/commands/setup-modules/provider.js +260 -260
  36. package/src/commands/setup-modules/safety.js +47 -47
  37. package/src/commands/setup-modules/simplify.js +52 -52
  38. package/src/commands/setup.js +172 -172
  39. package/src/common/assets.js +259 -259
  40. package/src/common/config.js +147 -147
  41. package/src/common/constants.js +55 -55
  42. package/src/common/indicator.js +260 -260
  43. package/src/common/interaction.js +170 -170
  44. package/src/common/logging.js +77 -77
  45. package/src/common/sdk.js +48 -48
  46. package/src/common/tasks.js +88 -88
  47. package/src/common/utils.js +215 -214
  48. package/src/core/coding.js +35 -35
  49. package/src/core/design.js +268 -268
  50. package/src/core/go.js +264 -264
  51. package/src/core/hooks.js +514 -514
  52. package/src/core/init.js +175 -175
  53. package/src/core/plan.js +194 -194
  54. package/src/core/prompts.js +292 -292
  55. package/src/core/repair.js +36 -36
  56. package/src/core/runner.js +438 -471
  57. package/src/core/scan.js +94 -94
  58. package/src/core/session.js +294 -294
  59. package/src/core/simplify.js +76 -76
  60. package/src/core/state.js +120 -120
  61. package/src/index.js +80 -80
  62. package/templates/coding/system.md +65 -65
  63. package/templates/coding/user.md +18 -18
  64. package/templates/design/base.md +103 -103
  65. package/templates/design/fixSystem.md +71 -71
  66. package/templates/design/fixUser.md +3 -3
  67. package/templates/design/init.md +304 -304
  68. package/templates/design/system.md +108 -108
  69. package/templates/design/user.md +11 -11
  70. package/templates/go/system.md +130 -130
  71. package/templates/other/bash-process.md +12 -12
  72. package/templates/other/coreProtocol.md +30 -30
  73. package/templates/other/guidance.json +72 -72
  74. package/templates/other/requirements.example.md +57 -57
  75. package/templates/other/test_rule.md +192 -192
  76. package/templates/other/web-testing.md +17 -17
  77. package/templates/plan/system.md +78 -78
  78. package/templates/plan/user.md +9 -9
  79. package/templates/scan/system.md +120 -120
  80. package/templates/scan/user.md +10 -10
  81. package/types/index.d.ts +217 -217
package/src/core/hooks.js CHANGED
@@ -1,515 +1,515 @@
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
- let re = ruleName && this._compiledConditions?.get(ruleName);
92
- if (!re) {
93
- try { re = new RegExp(condition.pattern, 'i'); } catch { return false; }
94
- }
95
- return re.test(String(value || ''));
96
- }
97
-
98
- if (condition.any && Array.isArray(condition.any)) {
99
- return condition.any.some(c => this.matchCondition(input, c));
100
- }
101
-
102
- return true;
103
- }
104
-
105
- /**
106
- * Extract tool tip key from tool name
107
- * @param {string} toolName - Full tool name (e.g., "mcp__playwright__browser_snapshot")
108
- * @param {string} extractor - Regex pattern to extract key
109
- */
110
- extractToolTipKey(toolName, extractor) {
111
- if (!extractor) return null;
112
- const match = toolName.match(new RegExp(extractor));
113
- return match ? match[1] : null;
114
- }
115
-
116
- /**
117
- * Get rule file content
118
- * Uses assets module for template resolution (supports user overrides + bundled fallback)
119
- * @param {object|string} file - File config or path string
120
- */
121
- getFileContent(file) {
122
- if (!file) return null;
123
-
124
- const filePath = typeof file === 'string' ? file : file.path;
125
-
126
- // Check if path matches a known registry entry (e.g., 'webTesting', 'bashProcess')
127
- // by converting 'assets/web-testing.md' → ['other', 'web-testing.md']
128
- if (filePath.startsWith('assets/')) {
129
- const segments = filePath.replace(/^assets\//, '').split('/');
130
- // Map to template directory structure: assets/web-testing.md → other/web-testing.md
131
- if (segments.length === 1) {
132
- segments.unshift('other');
133
- }
134
- const resolved = assets._resolveTemplate?.(segments);
135
- if (resolved) {
136
- try { return fs.readFileSync(resolved, 'utf8'); } catch { /* fall through */ }
137
- }
138
- }
139
-
140
- // Fallback: try direct path resolution
141
- const absolutePath = path.isAbsolute(filePath) ? filePath : assets.path(filePath);
142
- if (absolutePath) {
143
- try { return fs.readFileSync(absolutePath, 'utf8'); } catch { /* ignore */ }
144
- }
145
-
146
- return null;
147
- }
148
-
149
- /**
150
- * Process a single rule and return guidance content
151
- */
152
- processRule(rule, input) {
153
- const matcherRe = this._compiledMatchers?.get(rule.name) ?? new RegExp(rule.matcher);
154
- if (!matcherRe.test(input.tool_name)) {
155
- return null;
156
- }
157
-
158
- if (!this.matchCondition(input, rule.condition, rule.name)) {
159
- return null;
160
- }
161
-
162
- const result = { guidance: '', tip: '' };
163
-
164
- // Process file content
165
- if (rule.file) {
166
- const fileConfig = typeof rule.file === 'object' ? rule.file : { path: rule.file };
167
- const injectOnce = fileConfig.injectOnce === true;
168
- const ruleKey = `${rule.name}_file`;
169
-
170
- // Skip if already injected and injectOnce is true
171
- if (injectOnce && this.injectedRules.has(ruleKey)) {
172
- // Don't inject file content again
173
- } else {
174
- if (injectOnce) this.injectedRules.add(ruleKey);
175
-
176
- // Get cached content or read file
177
- const cacheKey = `${rule.name}_content`;
178
- if (!this.cache[cacheKey]) {
179
- this.cache[cacheKey] = this.getFileContent(fileConfig.path);
180
- }
181
- result.guidance = this.cache[cacheKey] || '';
182
- }
183
- }
184
-
185
- // Process tool tips
186
- if (rule.toolTips && rule.toolTips.items) {
187
- const tipKey = this.extractToolTipKey(input.tool_name, rule.toolTips.extractor);
188
- if (tipKey && rule.toolTips.items[tipKey]) {
189
- const tipInjectOnce = rule.toolTips.injectOnce !== false; // Default true
190
- const tipRuleKey = `${rule.name}_tip_${tipKey}`;
191
-
192
- if (!tipInjectOnce || !this.injectedRules.has(tipRuleKey)) {
193
- if (tipInjectOnce) this.injectedRules.add(tipRuleKey);
194
- result.tip = rule.toolTips.items[tipKey];
195
- }
196
- }
197
- }
198
-
199
- return result;
200
- }
201
-
202
- /**
203
- * Reset per-session state for clean session boundaries.
204
- * Also clears loaded flag so guidance.json is re-read on next hook call.
205
- */
206
- reset() {
207
- this.injectedRules.clear();
208
- this.cache = {};
209
- this.loaded = false;
210
- }
211
-
212
- /**
213
- * Create hook function for PreToolUse
214
- */
215
- createHook() {
216
- return async (input, _toolUseID, _context) => {
217
- this.load();
218
-
219
- if (this.rules.length === 0) return {};
220
-
221
- const guidanceParts = [];
222
- const tipParts = [];
223
-
224
- for (const rule of this.rules) {
225
- const result = this.processRule(rule, input);
226
- if (result) {
227
- if (result.guidance) guidanceParts.push(result.guidance);
228
- if (result.tip) tipParts.push(result.tip);
229
- }
230
- }
231
-
232
- const allParts = [...guidanceParts, ...tipParts];
233
- if (allParts.length === 0) return {};
234
-
235
- return {
236
- hookSpecificOutput: {
237
- hookEventName: 'PreToolUse',
238
- additionalContext: allParts.join('\n\n'),
239
- }
240
- };
241
- };
242
- }
243
- }
244
-
245
- // Shared instance (reset per session via createGuidanceModule)
246
- const guidanceInjector = new GuidanceInjector();
247
-
248
- // ─────────────────────────────────────────────────────────────
249
- // Utility Functions
250
- // ─────────────────────────────────────────────────────────────
251
-
252
-
253
- function logToolCall(logStream, input) {
254
- if (!logStream) return;
255
- const target = input.tool_input?.file_path || input.tool_input?.path || '';
256
- const cmd = input.tool_input?.command || '';
257
- const pattern = input.tool_input?.pattern || '';
258
- const detail = target || cmd.slice(0, 200) || (pattern ? `pattern: ${pattern}` : '');
259
- if (detail) {
260
- logStream.write(`[${localTimestamp()}] ${input.tool_name}: ${detail}\n`);
261
- }
262
- }
263
-
264
- // ─────────────────────────────────────────────────────────────
265
- // Module Factories
266
- // ─────────────────────────────────────────────────────────────
267
-
268
- /**
269
- * Create guidance injection module.
270
- * Resets the shared injector's per-session state to prevent cross-session leaks.
271
- */
272
- function createGuidanceModule() {
273
- guidanceInjector.reset();
274
- return {
275
- hook: guidanceInjector.createHook()
276
- };
277
- }
278
-
279
- /**
280
- * Create edit guard module.
281
- * Uses a sliding time window: edits older than cooldownMs are decayed,
282
- * allowing the model to resume editing after a "thinking" break.
283
- */
284
- function createEditGuardModule(options) {
285
- const editTimestamps = {};
286
- const threshold = options.editThreshold || DEFAULT_EDIT_THRESHOLD;
287
- const cooldownMs = options.editCooldownMs || 60000;
288
-
289
- return {
290
- hook: async (input, _toolUseID, _context) => {
291
- if (!['Write', 'Edit', 'MultiEdit'].includes(input.tool_name)) return {};
292
- const target = input.tool_input?.file_path || input.tool_input?.path || '';
293
- if (!target) return {};
294
-
295
- const now = Date.now();
296
- if (!editTimestamps[target]) editTimestamps[target] = [];
297
-
298
- editTimestamps[target] = editTimestamps[target].filter(t => now - t < cooldownMs);
299
- editTimestamps[target].push(now);
300
-
301
- if (editTimestamps[target].length > threshold) {
302
- return {
303
- hookSpecificOutput: {
304
- hookEventName: 'PreToolUse',
305
- permissionDecision: 'deny',
306
- permissionDecisionReason:
307
- `${cooldownMs / 1000}s 内对 ${target} 编辑 ${editTimestamps[target].length} 次(上限 ${threshold}),疑似死循环。请重新审视方案后再继续。`
308
- }
309
- };
310
- }
311
- return {};
312
- }
313
- };
314
- }
315
-
316
- /**
317
- * Create stall detection module (idle timeout only)
318
- */
319
- function createStallModule(indicator, logStream, options) {
320
- let stallDetected = false;
321
- let stallChecker = null;
322
- const timeoutMs = options.stallTimeoutMs || 1200000;
323
- const abortController = options.abortController;
324
-
325
- const checkStall = () => {
326
- const now = Date.now();
327
- const idleMs = now - indicator.lastActivityTime;
328
-
329
- if (idleMs > timeoutMs && !stallDetected) {
330
- stallDetected = true;
331
- const idleMin = Math.floor(idleMs / 60000);
332
- log('warn', `\n无响应超过 ${idleMin} 分钟,自动中断 session`);
333
- if (logStream) {
334
- logStream.write(`\n[${localTimestamp()}] STALL: 无响应 ${idleMin} 分钟,自动中断\n`);
335
- }
336
- if (abortController) {
337
- abortController.abort();
338
- log('warn', '\n已发送中断信号');
339
- }
340
- }
341
- };
342
-
343
- stallChecker = setInterval(checkStall, 30000);
344
-
345
- return {
346
- cleanup: () => { if (stallChecker) clearInterval(stallChecker); },
347
- isStalled: () => stallDetected
348
- };
349
- }
350
-
351
- /**
352
- * Create Stop hook — per-turn activity logger.
353
- * Stop fires on EVERY model response turn (not just session end).
354
- * For session completion detection, use the result message (SDKResultMessage.subtype).
355
- */
356
- function createStopHook(logStream) {
357
- return async (_input) => {
358
- if (logStream?.writable) {
359
- logStream.write(`[${localTimestamp()}] STOP: turn completed\n`);
360
- }
361
- return {};
362
- };
363
- }
364
-
365
- /**
366
- * Create PostToolUse hook — resets tool running state and activity timer.
367
- * Unified for all session types (replaces the former createCompletionModule).
368
- */
369
- function createEndToolHook(indicator) {
370
- return async (_input, _toolUseID, _context) => {
371
- indicator.endTool();
372
- indicator.updatePhase('thinking');
373
- return {};
374
- };
375
- }
376
-
377
- /**
378
- * Create PostToolUseFailure hook to ensure endTool on tool errors
379
- */
380
- function createFailureHook(indicator) {
381
- return async (_input, _toolUseID, _context) => {
382
- indicator.endTool();
383
- return {};
384
- };
385
- }
386
-
387
- /**
388
- * Create logging hook
389
- */
390
- function createLoggingHook(indicator, logStream) {
391
- return async (input, _toolUseID, _context) => {
392
- inferPhaseStep(indicator, input.tool_name, input.tool_input);
393
- logToolCall(logStream, input);
394
- return {};
395
- };
396
- }
397
-
398
- // ─────────────────────────────────────────────────────────────
399
- // Hook Factory: createHooks
400
- // ─────────────────────────────────────────────────────────────
401
-
402
- const FEATURE_MAP = {
403
- coding: [FEATURES.GUIDANCE, FEATURES.EDIT_GUARD, FEATURES.STOP, FEATURES.STALL],
404
- plan: [FEATURES.STOP, FEATURES.STALL],
405
- plan_interactive: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
406
- scan: [FEATURES.STOP, FEATURES.STALL],
407
- add: [FEATURES.STOP, FEATURES.STALL],
408
- simplify: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
409
- go: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
410
- design: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
411
- custom: null
412
- };
413
-
414
- /**
415
- * Create hooks based on session type
416
- */
417
- function createHooks(type, indicator, logStream, options = {}) {
418
- const features = type === 'custom'
419
- ? (options.features || [FEATURES.STALL])
420
- : (FEATURE_MAP[type] || [FEATURES.STALL]);
421
-
422
- const modules = {};
423
-
424
- if (features.includes(FEATURES.STALL)) {
425
- modules.stall = createStallModule(indicator, logStream, options);
426
- }
427
-
428
- if (features.includes(FEATURES.STOP)) {
429
- modules.stopHook = createStopHook(logStream);
430
- }
431
-
432
- if (features.includes(FEATURES.EDIT_GUARD)) {
433
- modules.editGuard = createEditGuardModule(options);
434
- }
435
-
436
- if (features.includes(FEATURES.GUIDANCE)) {
437
- modules.guidance = createGuidanceModule();
438
- }
439
-
440
- if (features.includes(FEATURES.INTERACTION)) {
441
- modules.interaction = { hook: createAskUserQuestionHook(indicator) };
442
- }
443
-
444
- // Assemble PreToolUse hooks
445
- const preToolUseHooks = [];
446
- preToolUseHooks.push(createLoggingHook(indicator, logStream));
447
-
448
- if (modules.editGuard) {
449
- preToolUseHooks.push(modules.editGuard.hook);
450
- }
451
-
452
- if (modules.guidance) {
453
- preToolUseHooks.push(modules.guidance.hook);
454
- }
455
-
456
- if (modules.interaction) {
457
- preToolUseHooks.push(modules.interaction.hook);
458
- }
459
-
460
- // PostToolUse: unified endTool for all session types
461
- const endToolHook = createEndToolHook(indicator);
462
-
463
- // PostToolUseFailure: ensure endTool even on tool errors
464
- const failureHook = createFailureHook(indicator);
465
-
466
- // Build hooks object
467
- const hooks = {};
468
- if (preToolUseHooks.length > 0) {
469
- hooks.PreToolUse = [{ matcher: '*', hooks: preToolUseHooks }];
470
- }
471
- hooks.PostToolUse = [{ matcher: '*', hooks: [endToolHook] }];
472
- hooks.PostToolUseFailure = [{ matcher: '*', hooks: [failureHook] }];
473
-
474
- // Stop hook: per-turn activity logger
475
- if (modules.stopHook) {
476
- hooks.Stop = [{ hooks: [modules.stopHook] }];
477
- }
478
-
479
- // SessionStart hook: log query lifecycle
480
- const sessionStartHook = async (input) => {
481
- indicator.updateActivity();
482
- if (logStream?.writable) {
483
- logStream.write(`[${localTimestamp()}] SESSION_START: source=${input.source || 'unknown'}\n`);
484
- }
485
- return {};
486
- };
487
- hooks.SessionStart = [{ hooks: [sessionStartHook] }];
488
-
489
- // Cleanup functions
490
- const cleanupFns = [];
491
- if (modules.stall) {
492
- cleanupFns.push(modules.stall.cleanup);
493
- }
494
-
495
- return {
496
- hooks,
497
- cleanup: () => cleanupFns.forEach(fn => fn()),
498
- isStalled: () => modules.stall?.isStalled() || false,
499
- };
500
- }
501
-
502
- // ─────────────────────────────────────────────────────────────
503
- // Exports
504
- // ─────────────────────────────────────────────────────────────
505
-
506
- module.exports = {
507
- createHooks,
508
- GuidanceInjector,
509
- createGuidanceModule,
510
- createEditGuardModule,
511
- createStopHook,
512
- createEndToolHook,
513
- createStallModule,
514
- FEATURES,
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
+ let re = ruleName && this._compiledConditions?.get(ruleName);
92
+ if (!re) {
93
+ try { re = new RegExp(condition.pattern, 'i'); } catch { return false; }
94
+ }
95
+ return re.test(String(value || ''));
96
+ }
97
+
98
+ if (condition.any && Array.isArray(condition.any)) {
99
+ return condition.any.some(c => this.matchCondition(input, c));
100
+ }
101
+
102
+ return true;
103
+ }
104
+
105
+ /**
106
+ * Extract tool tip key from tool name
107
+ * @param {string} toolName - Full tool name (e.g., "mcp__playwright__browser_snapshot")
108
+ * @param {string} extractor - Regex pattern to extract key
109
+ */
110
+ extractToolTipKey(toolName, extractor) {
111
+ if (!extractor) return null;
112
+ const match = toolName.match(new RegExp(extractor));
113
+ return match ? match[1] : null;
114
+ }
115
+
116
+ /**
117
+ * Get rule file content
118
+ * Uses assets module for template resolution (supports user overrides + bundled fallback)
119
+ * @param {object|string} file - File config or path string
120
+ */
121
+ getFileContent(file) {
122
+ if (!file) return null;
123
+
124
+ const filePath = typeof file === 'string' ? file : file.path;
125
+
126
+ // Check if path matches a known registry entry (e.g., 'webTesting', 'bashProcess')
127
+ // by converting 'assets/web-testing.md' → ['other', 'web-testing.md']
128
+ if (filePath.startsWith('assets/')) {
129
+ const segments = filePath.replace(/^assets\//, '').split('/');
130
+ // Map to template directory structure: assets/web-testing.md → other/web-testing.md
131
+ if (segments.length === 1) {
132
+ segments.unshift('other');
133
+ }
134
+ const resolved = assets._resolveTemplate?.(segments);
135
+ if (resolved) {
136
+ try { return fs.readFileSync(resolved, 'utf8'); } catch { /* fall through */ }
137
+ }
138
+ }
139
+
140
+ // Fallback: try direct path resolution
141
+ const absolutePath = path.isAbsolute(filePath) ? filePath : assets.path(filePath);
142
+ if (absolutePath) {
143
+ try { return fs.readFileSync(absolutePath, 'utf8'); } catch { /* ignore */ }
144
+ }
145
+
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Process a single rule and return guidance content
151
+ */
152
+ processRule(rule, input) {
153
+ const matcherRe = this._compiledMatchers?.get(rule.name) ?? new RegExp(rule.matcher);
154
+ if (!matcherRe.test(input.tool_name)) {
155
+ return null;
156
+ }
157
+
158
+ if (!this.matchCondition(input, rule.condition, rule.name)) {
159
+ return null;
160
+ }
161
+
162
+ const result = { guidance: '', tip: '' };
163
+
164
+ // Process file content
165
+ if (rule.file) {
166
+ const fileConfig = typeof rule.file === 'object' ? rule.file : { path: rule.file };
167
+ const injectOnce = fileConfig.injectOnce === true;
168
+ const ruleKey = `${rule.name}_file`;
169
+
170
+ // Skip if already injected and injectOnce is true
171
+ if (injectOnce && this.injectedRules.has(ruleKey)) {
172
+ // Don't inject file content again
173
+ } else {
174
+ if (injectOnce) this.injectedRules.add(ruleKey);
175
+
176
+ // Get cached content or read file
177
+ const cacheKey = `${rule.name}_content`;
178
+ if (!this.cache[cacheKey]) {
179
+ this.cache[cacheKey] = this.getFileContent(fileConfig.path);
180
+ }
181
+ result.guidance = this.cache[cacheKey] || '';
182
+ }
183
+ }
184
+
185
+ // Process tool tips
186
+ if (rule.toolTips && rule.toolTips.items) {
187
+ const tipKey = this.extractToolTipKey(input.tool_name, rule.toolTips.extractor);
188
+ if (tipKey && rule.toolTips.items[tipKey]) {
189
+ const tipInjectOnce = rule.toolTips.injectOnce !== false; // Default true
190
+ const tipRuleKey = `${rule.name}_tip_${tipKey}`;
191
+
192
+ if (!tipInjectOnce || !this.injectedRules.has(tipRuleKey)) {
193
+ if (tipInjectOnce) this.injectedRules.add(tipRuleKey);
194
+ result.tip = rule.toolTips.items[tipKey];
195
+ }
196
+ }
197
+ }
198
+
199
+ return result;
200
+ }
201
+
202
+ /**
203
+ * Reset per-session state for clean session boundaries.
204
+ * Also clears loaded flag so guidance.json is re-read on next hook call.
205
+ */
206
+ reset() {
207
+ this.injectedRules.clear();
208
+ this.cache = {};
209
+ this.loaded = false;
210
+ }
211
+
212
+ /**
213
+ * Create hook function for PreToolUse
214
+ */
215
+ createHook() {
216
+ return async (input, _toolUseID, _context) => {
217
+ this.load();
218
+
219
+ if (this.rules.length === 0) return {};
220
+
221
+ const guidanceParts = [];
222
+ const tipParts = [];
223
+
224
+ for (const rule of this.rules) {
225
+ const result = this.processRule(rule, input);
226
+ if (result) {
227
+ if (result.guidance) guidanceParts.push(result.guidance);
228
+ if (result.tip) tipParts.push(result.tip);
229
+ }
230
+ }
231
+
232
+ const allParts = [...guidanceParts, ...tipParts];
233
+ if (allParts.length === 0) return {};
234
+
235
+ return {
236
+ hookSpecificOutput: {
237
+ hookEventName: 'PreToolUse',
238
+ additionalContext: allParts.join('\n\n'),
239
+ }
240
+ };
241
+ };
242
+ }
243
+ }
244
+
245
+ // Shared instance (reset per session via createGuidanceModule)
246
+ const guidanceInjector = new GuidanceInjector();
247
+
248
+ // ─────────────────────────────────────────────────────────────
249
+ // Utility Functions
250
+ // ─────────────────────────────────────────────────────────────
251
+
252
+
253
+ function logToolCall(logStream, input) {
254
+ if (!logStream) return;
255
+ const target = input.tool_input?.file_path || input.tool_input?.path || '';
256
+ const cmd = input.tool_input?.command || '';
257
+ const pattern = input.tool_input?.pattern || '';
258
+ const detail = target || cmd.slice(0, 200) || (pattern ? `pattern: ${pattern}` : '');
259
+ if (detail) {
260
+ logStream.write(`[${localTimestamp()}] ${input.tool_name}: ${detail}\n`);
261
+ }
262
+ }
263
+
264
+ // ─────────────────────────────────────────────────────────────
265
+ // Module Factories
266
+ // ─────────────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Create guidance injection module.
270
+ * Resets the shared injector's per-session state to prevent cross-session leaks.
271
+ */
272
+ function createGuidanceModule() {
273
+ guidanceInjector.reset();
274
+ return {
275
+ hook: guidanceInjector.createHook()
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Create edit guard module.
281
+ * Uses a sliding time window: edits older than cooldownMs are decayed,
282
+ * allowing the model to resume editing after a "thinking" break.
283
+ */
284
+ function createEditGuardModule(options) {
285
+ const editTimestamps = {};
286
+ const threshold = options.editThreshold || DEFAULT_EDIT_THRESHOLD;
287
+ const cooldownMs = options.editCooldownMs || 60000;
288
+
289
+ return {
290
+ hook: async (input, _toolUseID, _context) => {
291
+ if (!['Write', 'Edit', 'MultiEdit'].includes(input.tool_name)) return {};
292
+ const target = input.tool_input?.file_path || input.tool_input?.path || '';
293
+ if (!target) return {};
294
+
295
+ const now = Date.now();
296
+ if (!editTimestamps[target]) editTimestamps[target] = [];
297
+
298
+ editTimestamps[target] = editTimestamps[target].filter(t => now - t < cooldownMs);
299
+ editTimestamps[target].push(now);
300
+
301
+ if (editTimestamps[target].length > threshold) {
302
+ return {
303
+ hookSpecificOutput: {
304
+ hookEventName: 'PreToolUse',
305
+ permissionDecision: 'deny',
306
+ permissionDecisionReason:
307
+ `${cooldownMs / 1000}s 内对 ${target} 编辑 ${editTimestamps[target].length} 次(上限 ${threshold}),疑似死循环。请重新审视方案后再继续。`
308
+ }
309
+ };
310
+ }
311
+ return {};
312
+ }
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Create stall detection module (idle timeout only)
318
+ */
319
+ function createStallModule(indicator, logStream, options) {
320
+ let stallDetected = false;
321
+ let stallChecker = null;
322
+ const timeoutMs = options.stallTimeoutMs || 1200000;
323
+ const abortController = options.abortController;
324
+
325
+ const checkStall = () => {
326
+ const now = Date.now();
327
+ const idleMs = now - indicator.lastActivityTime;
328
+
329
+ if (idleMs > timeoutMs && !stallDetected) {
330
+ stallDetected = true;
331
+ const idleMin = Math.floor(idleMs / 60000);
332
+ log('warn', `\n无响应超过 ${idleMin} 分钟,自动中断 session`);
333
+ if (logStream) {
334
+ logStream.write(`\n[${localTimestamp()}] STALL: 无响应 ${idleMin} 分钟,自动中断\n`);
335
+ }
336
+ if (abortController) {
337
+ abortController.abort();
338
+ log('warn', '\n已发送中断信号');
339
+ }
340
+ }
341
+ };
342
+
343
+ stallChecker = setInterval(checkStall, 30000);
344
+
345
+ return {
346
+ cleanup: () => { if (stallChecker) clearInterval(stallChecker); },
347
+ isStalled: () => stallDetected
348
+ };
349
+ }
350
+
351
+ /**
352
+ * Create Stop hook — per-turn activity logger.
353
+ * Stop fires on EVERY model response turn (not just session end).
354
+ * For session completion detection, use the result message (SDKResultMessage.subtype).
355
+ */
356
+ function createStopHook(logStream) {
357
+ return async (_input) => {
358
+ if (logStream?.writable) {
359
+ logStream.write(`[${localTimestamp()}] STOP: turn completed\n`);
360
+ }
361
+ return {};
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Create PostToolUse hook — resets tool running state and activity timer.
367
+ * Unified for all session types (replaces the former createCompletionModule).
368
+ */
369
+ function createEndToolHook(indicator) {
370
+ return async (_input, _toolUseID, _context) => {
371
+ indicator.endTool();
372
+ indicator.updatePhase('thinking');
373
+ return {};
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Create PostToolUseFailure hook to ensure endTool on tool errors
379
+ */
380
+ function createFailureHook(indicator) {
381
+ return async (_input, _toolUseID, _context) => {
382
+ indicator.endTool();
383
+ return {};
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Create logging hook
389
+ */
390
+ function createLoggingHook(indicator, logStream) {
391
+ return async (input, _toolUseID, _context) => {
392
+ inferPhaseStep(indicator, input.tool_name, input.tool_input);
393
+ logToolCall(logStream, input);
394
+ return {};
395
+ };
396
+ }
397
+
398
+ // ─────────────────────────────────────────────────────────────
399
+ // Hook Factory: createHooks
400
+ // ─────────────────────────────────────────────────────────────
401
+
402
+ const FEATURE_MAP = {
403
+ coding: [FEATURES.GUIDANCE, FEATURES.EDIT_GUARD, FEATURES.STOP, FEATURES.STALL],
404
+ plan: [FEATURES.STOP, FEATURES.STALL],
405
+ plan_interactive: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
406
+ scan: [FEATURES.STOP, FEATURES.STALL],
407
+ add: [FEATURES.STOP, FEATURES.STALL],
408
+ simplify: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
409
+ go: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
410
+ design: [FEATURES.STOP, FEATURES.STALL, FEATURES.INTERACTION],
411
+ custom: null
412
+ };
413
+
414
+ /**
415
+ * Create hooks based on session type
416
+ */
417
+ function createHooks(type, indicator, logStream, options = {}) {
418
+ const features = type === 'custom'
419
+ ? (options.features || [FEATURES.STALL])
420
+ : (FEATURE_MAP[type] || [FEATURES.STALL]);
421
+
422
+ const modules = {};
423
+
424
+ if (features.includes(FEATURES.STALL)) {
425
+ modules.stall = createStallModule(indicator, logStream, options);
426
+ }
427
+
428
+ if (features.includes(FEATURES.STOP)) {
429
+ modules.stopHook = createStopHook(logStream);
430
+ }
431
+
432
+ if (features.includes(FEATURES.EDIT_GUARD)) {
433
+ modules.editGuard = createEditGuardModule(options);
434
+ }
435
+
436
+ if (features.includes(FEATURES.GUIDANCE)) {
437
+ modules.guidance = createGuidanceModule();
438
+ }
439
+
440
+ if (features.includes(FEATURES.INTERACTION)) {
441
+ modules.interaction = { hook: createAskUserQuestionHook(indicator) };
442
+ }
443
+
444
+ // Assemble PreToolUse hooks
445
+ const preToolUseHooks = [];
446
+ preToolUseHooks.push(createLoggingHook(indicator, logStream));
447
+
448
+ if (modules.editGuard) {
449
+ preToolUseHooks.push(modules.editGuard.hook);
450
+ }
451
+
452
+ if (modules.guidance) {
453
+ preToolUseHooks.push(modules.guidance.hook);
454
+ }
455
+
456
+ if (modules.interaction) {
457
+ preToolUseHooks.push(modules.interaction.hook);
458
+ }
459
+
460
+ // PostToolUse: unified endTool for all session types
461
+ const endToolHook = createEndToolHook(indicator);
462
+
463
+ // PostToolUseFailure: ensure endTool even on tool errors
464
+ const failureHook = createFailureHook(indicator);
465
+
466
+ // Build hooks object
467
+ const hooks = {};
468
+ if (preToolUseHooks.length > 0) {
469
+ hooks.PreToolUse = [{ matcher: '*', hooks: preToolUseHooks }];
470
+ }
471
+ hooks.PostToolUse = [{ matcher: '*', hooks: [endToolHook] }];
472
+ hooks.PostToolUseFailure = [{ matcher: '*', hooks: [failureHook] }];
473
+
474
+ // Stop hook: per-turn activity logger
475
+ if (modules.stopHook) {
476
+ hooks.Stop = [{ hooks: [modules.stopHook] }];
477
+ }
478
+
479
+ // SessionStart hook: log query lifecycle
480
+ const sessionStartHook = async (input) => {
481
+ indicator.updateActivity();
482
+ if (logStream?.writable) {
483
+ logStream.write(`[${localTimestamp()}] SESSION_START: source=${input.source || 'unknown'}\n`);
484
+ }
485
+ return {};
486
+ };
487
+ hooks.SessionStart = [{ hooks: [sessionStartHook] }];
488
+
489
+ // Cleanup functions
490
+ const cleanupFns = [];
491
+ if (modules.stall) {
492
+ cleanupFns.push(modules.stall.cleanup);
493
+ }
494
+
495
+ return {
496
+ hooks,
497
+ cleanup: () => cleanupFns.forEach(fn => fn()),
498
+ isStalled: () => modules.stall?.isStalled() || false,
499
+ };
500
+ }
501
+
502
+ // ─────────────────────────────────────────────────────────────
503
+ // Exports
504
+ // ─────────────────────────────────────────────────────────────
505
+
506
+ module.exports = {
507
+ createHooks,
508
+ GuidanceInjector,
509
+ createGuidanceModule,
510
+ createEditGuardModule,
511
+ createStopHook,
512
+ createEndToolHook,
513
+ createStallModule,
514
+ FEATURES,
515
515
  };