@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.
- package/dist/cli/hooks.d.ts +13 -1
- package/dist/cli/hooks.js +75 -2
- package/dist/cli/index.js +94 -4
- package/dist/commands/accuracy.js +5 -1
- package/dist/commands/knowledge.d.ts +21 -0
- package/dist/commands/knowledge.js +80 -0
- package/dist/commands/progress.d.ts +66 -0
- package/dist/commands/progress.js +306 -1
- package/dist/core/archive.js +28 -0
- package/dist/core/configurators/codex.d.ts +55 -1
- package/dist/core/configurators/codex.js +234 -2
- package/dist/core/hooks/collect-knowledge.js +66 -0
- package/dist/core/hooks/deviation-check.d.ts +26 -1
- package/dist/core/hooks/deviation-check.js +32 -184
- package/dist/core/hooks/knowledge-index.d.ts +84 -0
- package/dist/core/hooks/knowledge-index.js +270 -0
- package/dist/core/hooks/record-progress.d.ts +63 -1
- package/dist/core/hooks/record-progress.js +208 -54
- package/dist/core/hooks/summarize.js +90 -2
- package/dist/core/hooks/user-input-hook.d.ts +38 -2
- package/dist/core/hooks/user-input-hook.js +346 -21
- package/dist/core/init.d.ts +11 -0
- package/dist/core/init.js +43 -4
- package/dist/core/metrics/code-accuracy.d.ts +152 -3
- package/dist/core/metrics/code-accuracy.js +323 -19
- package/dist/core/templates/agents-root-stub.d.ts +1 -1
- package/dist/core/templates/agents-root-stub.js +1 -0
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +21 -4
- package/dist/core/templates/codex-hooks-template.d.ts +37 -0
- package/dist/core/templates/codex-hooks-template.js +33 -2
- package/dist/core/templates/slash-command-templates.js +144 -28
- package/dist/core/templates/tasks-template.js +66 -14
- package/dist/core/update.js +17 -0
- package/dist/core/validation/strict-rules.d.ts +28 -0
- package/dist/core/validation/strict-rules.js +284 -0
- package/dist/utils/hook-merge.d.ts +6 -0
- package/dist/utils/hook-merge.js +33 -2
- package/package.json +1 -1
|
@@ -1,12 +1,75 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* User Input Hook
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
81
|
-
const
|
|
82
|
-
//
|
|
83
|
-
|
|
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
|
|
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: [
|
|
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: [
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
runUserInputHook();
|
|
178
|
-
}
|
|
502
|
+
// 此文件仅作为库被 cli/hooks.ts 导入,CLI 入口由 commander 注册;
|
|
503
|
+
// ESM 下无需也不应使用 require.main 判定。
|
|
179
504
|
//# sourceMappingURL=user-input-hook.js.map
|
package/dist/core/init.d.ts
CHANGED
|
@@ -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
|
|
1271
|
-
statusMessage: '
|
|
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
|
-
|
|
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] = [...
|
|
1435
|
+
existingHooks[hookType] = [...cleanedExisting, ...uniqueToAppend];
|
|
1397
1436
|
}
|
|
1398
1437
|
else {
|
|
1399
1438
|
// Create new hook type with ZhuanSpec config
|