@zhuan-ai/zhuanspec 2.15.7 → 2.16.0

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 (39) hide show
  1. package/dist/cli/hooks.d.ts +13 -1
  2. package/dist/cli/hooks.js +75 -2
  3. package/dist/cli/index.js +94 -4
  4. package/dist/commands/accuracy.js +5 -1
  5. package/dist/commands/knowledge.d.ts +21 -0
  6. package/dist/commands/knowledge.js +80 -0
  7. package/dist/commands/progress.d.ts +66 -0
  8. package/dist/commands/progress.js +306 -1
  9. package/dist/core/archive.js +28 -0
  10. package/dist/core/configurators/codex.d.ts +55 -1
  11. package/dist/core/configurators/codex.js +234 -2
  12. package/dist/core/hooks/collect-knowledge.js +66 -0
  13. package/dist/core/hooks/deviation-check.d.ts +26 -1
  14. package/dist/core/hooks/deviation-check.js +32 -184
  15. package/dist/core/hooks/knowledge-index.d.ts +84 -0
  16. package/dist/core/hooks/knowledge-index.js +270 -0
  17. package/dist/core/hooks/record-progress.d.ts +63 -1
  18. package/dist/core/hooks/record-progress.js +208 -54
  19. package/dist/core/hooks/summarize.js +90 -2
  20. package/dist/core/hooks/user-input-hook.d.ts +38 -2
  21. package/dist/core/hooks/user-input-hook.js +346 -21
  22. package/dist/core/init.d.ts +11 -0
  23. package/dist/core/init.js +43 -4
  24. package/dist/core/metrics/code-accuracy.d.ts +152 -3
  25. package/dist/core/metrics/code-accuracy.js +323 -19
  26. package/dist/core/templates/agents-root-stub.d.ts +1 -1
  27. package/dist/core/templates/agents-root-stub.js +1 -0
  28. package/dist/core/templates/agents-template.d.ts +1 -1
  29. package/dist/core/templates/agents-template.js +21 -4
  30. package/dist/core/templates/codex-hooks-template.d.ts +37 -0
  31. package/dist/core/templates/codex-hooks-template.js +33 -2
  32. package/dist/core/templates/slash-command-templates.js +144 -28
  33. package/dist/core/templates/tasks-template.js +66 -14
  34. package/dist/core/update.js +17 -0
  35. package/dist/core/validation/strict-rules.d.ts +28 -0
  36. package/dist/core/validation/strict-rules.js +284 -0
  37. package/dist/utils/hook-merge.d.ts +6 -0
  38. package/dist/utils/hook-merge.js +33 -2
  39. package/package.json +1 -1
@@ -1,12 +1,75 @@
1
1
  /**
2
2
  * User Input Hook
3
3
  *
4
- * Records user input summary during ZhuanSpec workflow.
5
- * Tracks user interventions, corrections, confirmations, and additional requests.
4
+ * 统一的 UserPromptSubmit 入口(v2.15.15 起接管 deviation-check 的纠偏/需求变更注入):
5
+ * 1. 过滤 slash/bang 命令、too-short 内容、Codex Ambient Suggestions、10s 窗口重复
6
+ * 2. 将 prompt 归档到 user_inputs.json(含 fullPrompt + correctionSignal + postBaseline)
7
+ * 3. baseline 已打标时写 correctionContext(Task 0 post-baseline-prompt 语义)
8
+ * 4. phase='apply' + postBaseline + 命中 hard 关键词
9
+ * → 注入四选一(纠偏)或 AD 两选一(需求变更)additionalContext
10
+ * → 同步写 .pending-correction 供 pre-tool hook 拦截 Edit/Write
11
+ *
12
+ * 与 deviation-check 的关系:
13
+ * - 本 hook 接管 UserPromptSubmit 下原 deviation-check 的全部职责(关键词 + 四选一)
14
+ * - deviation-check 保留 pre-tool 分支(检测 .pending-correction → 拦截 Edit/Write)
15
+ * - settings.json 模板不再挂 deviation-check 到 UserPromptSubmit;旧项目请跑 zhuanspec update
16
+ *
17
+ * v2.15.16 Sprint 3:.correction-log 写入已下线,user_inputs.json 为唯一纠偏事实源。
6
18
  */
7
19
  import path from 'path';
20
+ import fs from 'fs';
21
+ import crypto from 'crypto';
8
22
  import { FileSystemUtils } from '../../utils/file-system.js';
9
- import { getBeijingTime } from './record-progress.js';
23
+ import { getBeijingTime, atomicWriteJson, detectActiveChange } from './record-progress.js';
24
+ import { detectHookHost, sanitizeCodexEnvelope } from '../../utils/hook-host.js';
25
+ import { USER_CORRECTION_KEYWORDS, REQUIREMENT_CHANGE_KEYWORDS, extractProposalSummary, buildCorrectionOptionsContext, buildRequirementChangeContext, isCodexInternalPrompt, } from './deviation-check.js';
26
+ /**
27
+ * Structured JSONL debug log for user-input hook.
28
+ * Writes to <cwd>/.user-input-debug.log; failures are swallowed to keep main flow intact.
29
+ * Every hook invocation MUST write exactly one line here, covering every branch
30
+ * (skip-*, record, baseline-miss, baseline-write) with positive reason codes.
31
+ */
32
+ function logDebug(cwd, payload) {
33
+ try {
34
+ const logPath = path.join(cwd, '.user-input-debug.log');
35
+ const line = JSON.stringify({ ts: getBeijingTime(), ...payload }) + '\n';
36
+ fs.appendFileSync(logPath, line, 'utf-8');
37
+ }
38
+ catch {
39
+ // Logging must never block the hook
40
+ }
41
+ }
42
+ /** fullPrompt 截断长度:够写知识文件的"背景/问题/解决方案",又不会把 user_inputs.json 撑爆 */
43
+ const FULL_PROMPT_MAX_LEN = 2000;
44
+ /**
45
+ * 基于硬关键词的 correctionSignal 判定。
46
+ * 规则:
47
+ * - 命中任一 USER_CORRECTION_KEYWORDS → kind='hard'、isRequirementChange=false
48
+ * - 命中任一 REQUIREMENT_CHANGE_KEYWORDS → kind='hard'、isRequirementChange=true(互斥优先级:纠偏 > 需求变更)
49
+ * - 都没命中 → kind='none'
50
+ */
51
+ function buildCorrectionSignal(content) {
52
+ const lowerContent = content.toLowerCase();
53
+ const hits = new Set();
54
+ for (const kw of USER_CORRECTION_KEYWORDS) {
55
+ if (lowerContent.includes(kw.toLowerCase()))
56
+ hits.add(kw);
57
+ }
58
+ const correctionHit = hits.size > 0;
59
+ const reqHits = new Set();
60
+ for (const kw of REQUIREMENT_CHANGE_KEYWORDS) {
61
+ if (lowerContent.includes(kw.toLowerCase()))
62
+ reqHits.add(kw);
63
+ }
64
+ const reqHit = reqHits.size > 0;
65
+ if (correctionHit) {
66
+ return { kind: 'hard', matchedKeywords: Array.from(hits).slice(0, 6), isRequirementChange: false };
67
+ }
68
+ if (reqHit) {
69
+ return { kind: 'hard', matchedKeywords: Array.from(reqHits).slice(0, 6), isRequirementChange: true };
70
+ }
71
+ return { kind: 'none', matchedKeywords: [], isRequirementChange: false };
72
+ }
10
73
  /**
11
74
  * Detect trigger type from user input content
12
75
  */
@@ -77,44 +140,113 @@ function generateSummary(content) {
77
140
  * Main hook function - records user input
78
141
  */
79
142
  export async function userInputHook(content, responseTimeMs) {
80
- const phase = process.env.ZHUANSPEC_PHASE || 'idle';
81
- const changeId = process.env.ZHUANSPEC_CHANGE_ID || '';
82
- // Skip if not in active phase
83
- if (!changeId || phase === 'idle') {
143
+ const cwd = process.cwd();
144
+ const contentLen = (content || '').length;
145
+ // === Resolve active change (filesystem detection first, env as fallback) ===
146
+ // Claude Code / Codex do NOT propagate SessionStart env vars into UserPromptSubmit
147
+ // subprocess, so we cannot rely on process.env here. Mirror record-progress.ts
148
+ // detection strategy to stay consistent with the rest of the hook family.
149
+ let phase = 'idle';
150
+ let changeId = '';
151
+ let phaseSource = 'none';
152
+ try {
153
+ const active = await detectActiveChange(cwd);
154
+ if (active?.changeId) {
155
+ phase = active.phase;
156
+ changeId = active.changeId;
157
+ phaseSource = 'detect';
158
+ }
159
+ else if (process.env.ZHUANSPEC_CHANGE_ID) {
160
+ phase = process.env.ZHUANSPEC_PHASE || 'idle';
161
+ changeId = process.env.ZHUANSPEC_CHANGE_ID;
162
+ phaseSource = 'env';
163
+ }
164
+ }
165
+ catch {
166
+ // Detection failure falls through to env fallback below
167
+ if (process.env.ZHUANSPEC_CHANGE_ID) {
168
+ phase = process.env.ZHUANSPEC_PHASE || 'idle';
169
+ changeId = process.env.ZHUANSPEC_CHANGE_ID;
170
+ phaseSource = 'env';
171
+ }
172
+ }
173
+ // Skip if no active change resolved
174
+ if (!changeId) {
175
+ logDebug(cwd, { event: 'skip', reason: 'no-active-change', phaseSource, contentLen });
176
+ return { continue: true };
177
+ }
178
+ // Skip if phase is idle (workflow not started)
179
+ if (phase === 'idle') {
180
+ logDebug(cwd, { event: 'skip', reason: 'phase-idle', changeId, phaseSource, contentLen });
84
181
  return { continue: true };
85
182
  }
86
- // Skip empty content
183
+ // Skip empty / too-short content
87
184
  if (!content || content.trim().length < 10) {
185
+ logDebug(cwd, { event: 'skip', reason: 'content-too-short', changeId, phase, phaseSource, contentLen });
186
+ return { continue: true };
187
+ }
188
+ // Skip slash/bang commands (e.g. /zhuanspec:proposal, /compact, /clear, !ls).
189
+ // 这类 prompt 是命令/workflow 触发,非“用户对 AI 产出的纠偏/濄清/补充”,
190
+ // 不应计入 user_inputs.json,更不应放进纠偏溯源池影响 accuracyRate。
191
+ const trimmedContent = content.trim();
192
+ if (/^[/!][A-Za-z0-9_:/@.\-]+(\s|$)/.test(trimmedContent) || /^\/[\w:\-]+$/.test(trimmedContent)) {
193
+ logDebug(cwd, {
194
+ event: 'skip',
195
+ reason: 'slash-command',
196
+ changeId,
197
+ phase,
198
+ phaseSource,
199
+ contentLen,
200
+ commandHead: trimmedContent.slice(0, 60),
201
+ });
202
+ return { continue: true };
203
+ }
204
+ // Skip Codex Ambient Suggestions(openai/codex#18541):CLI 内部生成的“环境提示”
205
+ // 会被错误地走 UserPromptSubmit 通道,复用 deviation-check 同款判定。
206
+ if (isCodexInternalPrompt(content)) {
207
+ logDebug(cwd, {
208
+ event: 'skip',
209
+ reason: 'codex-ambient',
210
+ changeId,
211
+ phase,
212
+ phaseSource,
213
+ contentLen,
214
+ });
88
215
  return { continue: true };
89
216
  }
90
- const cwd = process.cwd();
91
217
  const zhuanspecDir = path.join(cwd, 'zhuanspec');
92
- const metricsDir = path.join(zhuanspecDir, 'changes', changeId, 'metrics');
218
+ const changeDir = path.join(zhuanspecDir, 'changes', changeId);
219
+ const metricsDir = path.join(changeDir, 'metrics');
220
+ const proposalPath = path.join(changeDir, 'proposal.md');
221
+ const pendingMarkerPath = path.join(changeDir, '.pending-correction');
93
222
  // Ensure metrics directory exists
94
223
  await FileSystemUtils.createDirectory(metricsDir);
95
224
  const userInputPath = path.join(metricsDir, 'user_inputs.json');
225
+ const progressPath = path.join(metricsDir, 'progress.json');
96
226
  // Create input record
227
+ const correctionSignal = buildCorrectionSignal(content);
97
228
  const inputRecord = {
98
229
  inputId: `input-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
99
230
  timestamp: getBeijingTime(),
100
231
  phase,
101
232
  summary: generateSummary(content),
233
+ fullPrompt: content.slice(0, FULL_PROMPT_MAX_LEN),
102
234
  triggerType: detectTriggerType(content),
103
235
  affectedFiles: extractAffectedFiles(content),
104
- responseTimeMs
236
+ responseTimeMs,
237
+ correctionSignal,
105
238
  };
106
239
  // Load existing user inputs or create new
107
240
  let userInputs;
108
241
  if (await FileSystemUtils.fileExists(userInputPath)) {
109
242
  try {
110
243
  userInputs = JSON.parse(await FileSystemUtils.readFile(userInputPath));
111
- userInputs.inputs.push(inputRecord);
112
244
  }
113
245
  catch {
114
246
  userInputs = {
115
247
  changeId,
116
248
  sessionId: `sess-${Date.now()}`,
117
- inputs: [inputRecord]
249
+ inputs: []
118
250
  };
119
251
  }
120
252
  }
@@ -122,11 +254,191 @@ export async function userInputHook(content, responseTimeMs) {
122
254
  userInputs = {
123
255
  changeId,
124
256
  sessionId: `sess-${Date.now()}`,
125
- inputs: [inputRecord]
257
+ inputs: []
258
+ };
259
+ }
260
+ // === 幂等去重:Claude Code / Codex 在 hook 输出校验失败或 sub-agent 复用时
261
+ // 可能对同一条用户输入多次 fire UserPromptSubmit,导致同一 prompt 被落盘多次。
262
+ // 规则:同 phase + 相同 summary hash + 10 秒内的记录视为重复,直接跳过。
263
+ const DEDUP_WINDOW_MS = 10_000;
264
+ const summaryHash = crypto.createHash('sha1').update(inputRecord.summary).digest('hex').slice(0, 16);
265
+ const nowMs = Date.now();
266
+ const dupLast = [...userInputs.inputs].reverse().find(i => {
267
+ if (i.phase !== phase)
268
+ return false;
269
+ const existingHash = crypto.createHash('sha1').update(i.summary || '').digest('hex').slice(0, 16);
270
+ if (existingHash !== summaryHash)
271
+ return false;
272
+ const ts = Date.parse(`${i.timestamp.replace(' ', 'T')}+08:00`);
273
+ if (Number.isNaN(ts))
274
+ return false;
275
+ return nowMs - ts <= DEDUP_WINDOW_MS;
276
+ });
277
+ if (dupLast) {
278
+ logDebug(cwd, {
279
+ event: 'skip',
280
+ reason: 'duplicate-within-window',
281
+ changeId,
282
+ phase,
283
+ phaseSource,
284
+ contentLen,
285
+ summaryHash,
286
+ dedupWindowMs: DEDUP_WINDOW_MS,
287
+ dupOfInputId: dupLast.inputId,
288
+ dupOfTimestamp: dupLast.timestamp,
289
+ });
290
+ return {
291
+ continue: true,
292
+ hookSpecificOutput: {
293
+ inputRecorded: false,
294
+ triggerType: dupLast.triggerType,
295
+ summaryLength: (dupLast.summary || '').length,
296
+ },
126
297
  };
127
298
  }
128
- // Write user inputs file
299
+ // 计算 phaseInputIndex:当前 phase 已有条数 + 1
300
+ const phaseInputIndex = userInputs.inputs.filter(i => i.phase === phase).length + 1;
301
+ inputRecord.phaseInputIndex = phaseInputIndex;
302
+ // === Task 0 + Task 4:若当前 phase 的 baseline 已打标,写入 correctionContext
303
+ // (将后续 Write/Edit 视为纠偏)。 source='post-baseline-prompt'
304
+ let baselineBranch = 'baseline-phase-skip';
305
+ let baselineMarkedFlag = false;
306
+ try {
307
+ if (['techDesign', 'propose', 'apply', 'review'].includes(phase)
308
+ && await FileSystemUtils.fileExists(progressPath)) {
309
+ const progress = JSON.parse(await FileSystemUtils.readFile(progressPath));
310
+ const key = phase;
311
+ baselineMarkedFlag = !!progress.phaseBaselines?.[key]?.markedAt;
312
+ if (baselineMarkedFlag) {
313
+ const now = Date.now();
314
+ const nowIso = getBeijingTime();
315
+ const expiresAt = new Date(now + 30 * 60 * 1000).toISOString();
316
+ const snippet = content.slice(0, 120);
317
+ const cc = {
318
+ active: true,
319
+ resolvedAt: nowIso,
320
+ path: 'A', // post-baseline-prompt 占位:语义上等价 “继续修改”
321
+ expiresAt,
322
+ phase,
323
+ source: 'post-baseline-prompt',
324
+ promptIndex: phaseInputIndex,
325
+ promptId: inputRecord.inputId,
326
+ promptSnippet: snippet,
327
+ };
328
+ progress.correctionContext = cc;
329
+ await atomicWriteJson(progressPath, progress);
330
+ baselineBranch = 'baseline-write';
331
+ }
332
+ else {
333
+ baselineBranch = 'baseline-miss';
334
+ }
335
+ }
336
+ }
337
+ catch {
338
+ baselineBranch = 'baseline-error';
339
+ // correctionContext 写入失败不阻断 user input 主流程
340
+ }
341
+ inputRecord.postBaseline = baselineMarkedFlag;
342
+ // === 四选一 / 需求变更注入(v2.15.15 接管自 deviation-check post-prompt 分支)
343
+ // 触发条件:apply 阶段 + baseline 已打标 + 命中硬关键词(不做冷却限制,每次命中都弹)
344
+ let injectedOutput = null;
345
+ let injectionEvent = 'no-hard-signal';
346
+ if (correctionSignal.kind === 'hard') {
347
+ if (phase !== 'apply') {
348
+ injectionEvent = 'phase-not-apply';
349
+ }
350
+ else if (!baselineMarkedFlag) {
351
+ injectionEvent = 'not-post-baseline';
352
+ }
353
+ else if (!await FileSystemUtils.fileExists(proposalPath)) {
354
+ injectionEvent = 'no-proposal';
355
+ }
356
+ else {
357
+ const isReqChange = !!correctionSignal.isRequirementChange;
358
+ try {
359
+ if (!fs.existsSync(changeDir))
360
+ fs.mkdirSync(changeDir, { recursive: true });
361
+ // 写 .pending-correction(兼容 pre-tool hook 拦截 + resolve-correction 回写)
362
+ fs.writeFileSync(pendingMarkerPath, JSON.stringify({
363
+ createdAt: Date.now(),
364
+ prompt: content.slice(0, 500),
365
+ changeId,
366
+ phase,
367
+ reason: isReqChange ? 'requirement-change' : undefined,
368
+ promptId: inputRecord.inputId,
369
+ phaseInputIndex,
370
+ promptSnippet: inputRecord.summary.slice(0, 120),
371
+ source: 'user-input-hook',
372
+ matchedKeywords: correctionSignal.matchedKeywords,
373
+ }, null, 2), 'utf-8');
374
+ // v2.15.16 Sprint 3:.correction-log 写入已下线,纠偏详情统一由 user_inputs.json 记录。
375
+ }
376
+ catch {
377
+ // 文件操作失败不阻断 additionalContext 注入
378
+ }
379
+ try {
380
+ const proposalContent = await FileSystemUtils.readFile(proposalPath);
381
+ const proposalSummary = extractProposalSummary(proposalContent, 1500);
382
+ const additionalContext = isReqChange
383
+ ? buildRequirementChangeContext(proposalSummary, changeId)
384
+ : buildCorrectionOptionsContext(proposalSummary, changeId);
385
+ const options = isReqChange
386
+ ? [
387
+ 'A. 先修 Spec — 先更新 proposal/tasks/specs,再改代码',
388
+ 'D. 取消本次需求变更',
389
+ ]
390
+ : [
391
+ 'A. 先修 Spec — 先更新 proposal/tasks/specs,再改代码',
392
+ 'B. Bug 修复豁免 — 直接改代码,本次不更新 Spec',
393
+ 'C. 纯设计细节 — 不改 Spec 不改代码,仅口头澄清',
394
+ 'D. 取消本次修改',
395
+ ];
396
+ inputRecord.resolution = {
397
+ ...(inputRecord.resolution ?? {}),
398
+ triggeredFourOption: true,
399
+ };
400
+ injectedOutput = {
401
+ continue: true,
402
+ systemMessage: isReqChange
403
+ ? '⚠️ 检测到需求变更信号——已注入需求变更上下文,请主 Agent 用 askUserQuestion 向用户确认。'
404
+ : '⚠️ 检测到用户纠偏信号——已注入纠偏四选一上下文,请主 Agent 用 askUserQuestion 向用户展示选项。',
405
+ hookSpecificOutput: {
406
+ inputRecorded: true,
407
+ triggerType: inputRecord.triggerType,
408
+ summaryLength: inputRecord.summary.length,
409
+ additionalContext,
410
+ options,
411
+ },
412
+ };
413
+ injectionEvent = isReqChange ? 'injected-requirement-change' : 'injected-correction';
414
+ }
415
+ catch {
416
+ injectionEvent = 'inject-error';
417
+ }
418
+ }
419
+ }
420
+ // Push and persist user inputs file(postBaseline / correctionSignal / resolution 一并落盘)
421
+ userInputs.inputs.push(inputRecord);
129
422
  await FileSystemUtils.writeFile(userInputPath, JSON.stringify(userInputs, null, 2));
423
+ logDebug(cwd, {
424
+ event: 'record',
425
+ changeId,
426
+ phase,
427
+ phaseSource,
428
+ triggerType: inputRecord.triggerType,
429
+ phaseInputIndex,
430
+ inputId: inputRecord.inputId,
431
+ contentLen,
432
+ baselineBranch,
433
+ baselineMarked: baselineMarkedFlag,
434
+ correctionKind: correctionSignal.kind,
435
+ matchedKeywords: correctionSignal.matchedKeywords,
436
+ isRequirementChange: !!correctionSignal.isRequirementChange,
437
+ injectionEvent,
438
+ });
439
+ if (injectedOutput) {
440
+ return injectedOutput;
441
+ }
130
442
  return {
131
443
  continue: true,
132
444
  systemMessage: `📝 User input recorded: ${inputRecord.triggerType} (${inputRecord.summary.length} chars)`,
@@ -152,7 +464,11 @@ export async function runUserInputHook() {
152
464
  catch {
153
465
  // Ignore parse errors
154
466
  }
155
- const content = stdinData.user_message || '';
467
+ // Claude Code / Codex UserPromptSubmit 的标准字段为 `prompt`;
468
+ // 兼容历史调用方使用 `user_message` 传入的情况。
469
+ const content = (stdinData.prompt
470
+ || stdinData.user_message
471
+ || '').toString();
156
472
  const responseTimeMs = typeof stdinData.response_time_ms === 'number'
157
473
  ? stdinData.response_time_ms
158
474
  : undefined;
@@ -161,7 +477,18 @@ export async function runUserInputHook() {
161
477
  return;
162
478
  }
163
479
  const output = await userInputHook(content, responseTimeMs);
164
- console.log(JSON.stringify(output));
480
+ // Claude Code requires `hookSpecificOutput.hookEventName` for every UserPromptSubmit
481
+ // hook response; omitting it fails JSON validation with the error:
482
+ // "Hook JSON output validation failed — hookSpecificOutput is missing required field hookEventName"
483
+ // Align with deviation-check.ts envelope handling for parity across hosts.
484
+ const hookEvent = 'UserPromptSubmit';
485
+ const jsonOutput = output.hookSpecificOutput
486
+ ? { ...output, hookSpecificOutput: { hookEventName: hookEvent, ...output.hookSpecificOutput } }
487
+ : output;
488
+ const payload = detectHookHost() === 'codex'
489
+ ? sanitizeCodexEnvelope(jsonOutput, hookEvent)
490
+ : jsonOutput;
491
+ console.log(JSON.stringify(payload));
165
492
  }
166
493
  async function readStdin() {
167
494
  return new Promise((resolve) => {
@@ -172,8 +499,6 @@ async function readStdin() {
172
499
  process.stdin.on('error', () => resolve(null));
173
500
  });
174
501
  }
175
- // CLI execution
176
- if (require.main === module) {
177
- runUserInputHook();
178
- }
502
+ // 此文件仅作为库被 cli/hooks.ts 导入,CLI 入口由 commander 注册;
503
+ // ESM 下无需也不应使用 require.main 判定。
179
504
  //# sourceMappingURL=user-input-hook.js.map
@@ -21,6 +21,10 @@ type ToolWizardConfig = {
21
21
  initialSelected?: string[];
22
22
  };
23
23
  type ToolSelectionPrompt = (config: ToolWizardConfig) => Promise<string[]>;
24
+ type ClaudeHookSyncSummary = {
25
+ totalAddedHooks: number;
26
+ duplicateHooksSkipped: number;
27
+ };
24
28
  type InitCommandOptions = {
25
29
  prompt?: ToolSelectionPrompt;
26
30
  tools?: string;
@@ -72,6 +76,13 @@ export declare class InitCommand {
72
76
  private writeTemplateFiles;
73
77
  private configureAITools;
74
78
  private configureRootAgentsStub;
79
+ /**
80
+ * v2.15.16:对外暴露的 settings.json 同步入口,给 zhuanspec update 复用。
81
+ * 会走 generateClaudeSettings 同样的 merge + 老 group 清理逻辑,
82
+ * 确保老版 settings.json(如还挂着 deviation-check --trigger post-prompt)
83
+ * 在升级后能被过滤掉。
84
+ */
85
+ syncClaudeSettings(projectPath: string): Promise<ClaudeHookSyncSummary>;
75
86
  /**
76
87
  * Generate Claude Code hooks configuration for ZhuanSpec workflow.
77
88
  * Creates .claude/settings.json with hooks for SessionStart, PreToolUse, PostToolUse, and Stop.
package/dist/core/init.js CHANGED
@@ -37,6 +37,25 @@ const parseToolLabel = (raw) => {
37
37
  };
38
38
  };
39
39
  const isSelectableChoice = (choice) => choice.selectable;
40
+ /**
41
+ * v2.15.16:判定一个 hook group 是否完全由 ZhuanSpec 管辖。
42
+ * 条件:group.hooks 里所有 entry 的 command 都以 `zhuanspec-hook` 开头。
43
+ * 任何一个非 ZhuanSpec 命令(用户自己挂的)都会让整组保留,避免误伤。
44
+ * 用于 zhuanspec update 时清理老模板挂载、但新模板已删除的 entry(如 Sprint 3 下线的 deviation-check --trigger post-prompt)。
45
+ */
46
+ function isZhuanspecManagedHookGroup(group) {
47
+ if (!group || typeof group !== 'object')
48
+ return false;
49
+ const hooks = group.hooks;
50
+ if (!Array.isArray(hooks) || hooks.length === 0)
51
+ return false;
52
+ return hooks.every((h) => {
53
+ if (!h || typeof h !== 'object')
54
+ return false;
55
+ const command = h.command;
56
+ return typeof command === 'string' && command.trim().startsWith('zhuanspec-hook');
57
+ });
58
+ }
40
59
  const ROOT_STUB_CHOICE_VALUE = '__root_stub__';
41
60
  const OTHER_TOOLS_HEADING_VALUE = '__heading-other__';
42
61
  const LIST_SPACER_VALUE = '__list-spacer__';
@@ -1237,6 +1256,15 @@ export class InitCommand {
1237
1256
  await configurator.configure(projectPath, zhuanspecDir);
1238
1257
  return existed ? 'updated' : 'created';
1239
1258
  }
1259
+ /**
1260
+ * v2.15.16:对外暴露的 settings.json 同步入口,给 zhuanspec update 复用。
1261
+ * 会走 generateClaudeSettings 同样的 merge + 老 group 清理逻辑,
1262
+ * 确保老版 settings.json(如还挂着 deviation-check --trigger post-prompt)
1263
+ * 在升级后能被过滤掉。
1264
+ */
1265
+ async syncClaudeSettings(projectPath) {
1266
+ return this.generateClaudeSettings(projectPath);
1267
+ }
1240
1268
  /**
1241
1269
  * Generate Claude Code hooks configuration for ZhuanSpec workflow.
1242
1270
  * Creates .claude/settings.json with hooks for SessionStart, PreToolUse, PostToolUse, and Stop.
@@ -1267,8 +1295,8 @@ export class InitCommand {
1267
1295
  hooks: [
1268
1296
  {
1269
1297
  type: 'command',
1270
- command: 'zhuanspec-hook deviation-check --trigger post-prompt',
1271
- statusMessage: 'Checking prompt vs proposal consistency...',
1298
+ command: 'zhuanspec-hook user-input',
1299
+ statusMessage: 'Recording user input & correction context...',
1272
1300
  },
1273
1301
  ],
1274
1302
  },
@@ -1381,7 +1409,18 @@ export class InitCommand {
1381
1409
  // Append ZhuanSpec hooks to existing hooks array while de-duplicating.
1382
1410
  const existingArray = existingHooks[hookType];
1383
1411
  const zhuanspecArray = zhuanspecHookConfig;
1384
- const existingSignatures = new Set(existingArray.map((item) => JSON.stringify(item)));
1412
+ // v2.15.16:先剔除所有纯 ZhuanSpec 管辖的老 group,再 append 新模板。
1413
+ // 防止 Sprint 3 下线的 deviation-check --trigger post-prompt 等旧 entry
1414
+ // 因老 settings.json 残留而永久活下来;非 ZhuanSpec entry(用户自己挂的)全量保留。
1415
+ const cleanedExisting = [];
1416
+ for (const item of existingArray) {
1417
+ if (isZhuanspecManagedHookGroup(item)) {
1418
+ // 被清理的老 group,这里静默丢掉(不计入 duplicateHooksSkipped)
1419
+ continue;
1420
+ }
1421
+ cleanedExisting.push(item);
1422
+ }
1423
+ const existingSignatures = new Set(cleanedExisting.map((item) => JSON.stringify(item)));
1385
1424
  const uniqueToAppend = [];
1386
1425
  for (const item of zhuanspecArray) {
1387
1426
  const signature = JSON.stringify(item);
@@ -1393,7 +1432,7 @@ export class InitCommand {
1393
1432
  uniqueToAppend.push(item);
1394
1433
  }
1395
1434
  summary.totalAddedHooks += uniqueToAppend.length;
1396
- existingHooks[hookType] = [...existingArray, ...uniqueToAppend];
1435
+ existingHooks[hookType] = [...cleanedExisting, ...uniqueToAppend];
1397
1436
  }
1398
1437
  else {
1399
1438
  // Create new hook type with ZhuanSpec config